| // Copyright 2025 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| #include "chrome/browser/actor/execution_engine.h" |
| |
| #include <cstddef> |
| #include <memory> |
| #include <optional> |
| #include <utility> |
| |
| #include "base/check.h" |
| #include "base/check_deref.h" |
| #include "base/feature_list.h" |
| #include "base/functional/callback.h" |
| #include "base/logging.h" |
| #include "base/memory/weak_ptr.h" |
| #include "base/metrics/histogram_macros.h" |
| #include "base/no_destructor.h" |
| #include "base/notimplemented.h" |
| #include "base/state_transitions.h" |
| #include "base/types/id_type.h" |
| #include "chrome/browser/actor/actor_keyed_service.h" |
| #include "chrome/browser/actor/actor_task.h" |
| #include "chrome/browser/actor/browser_action_util.h" |
| #include "chrome/browser/actor/site_policy.h" |
| #include "chrome/browser/actor/task_id.h" |
| #include "chrome/browser/actor/tools/tool_controller.h" |
| #include "chrome/browser/actor/tools/tool_request.h" |
| #include "chrome/browser/actor/ui/event_dispatcher.h" |
| #include "chrome/browser/password_manager/actor_login/actor_login_service.h" |
| #include "chrome/browser/password_manager/actor_login/actor_login_service_impl.h" |
| #include "chrome/browser/profiles/profile.h" |
| #include "chrome/browser/ui/browser_navigator.h" |
| #include "chrome/browser/ui/browser_navigator_params.h" |
| #include "chrome/common/actor.mojom.h" |
| #include "chrome/common/actor/action_result.h" |
| #include "chrome/common/chrome_features.h" |
| #include "components/optimization_guide/content/browser/page_content_proto_provider.h" |
| #include "components/optimization_guide/proto/features/actions_data.pb.h" |
| #include "components/tabs/public/tab_interface.h" |
| #include "content/public/browser/navigation_handle.h" |
| #include "content/public/browser/render_frame_host.h" |
| #include "content/public/browser/web_contents.h" |
| #include "content/public/browser/web_contents_observer.h" |
| #include "mojo/public/cpp/base/proto_wrapper.h" |
| #include "third_party/abseil-cpp/absl/container/flat_hash_set.h" |
| #include "ui/event_dispatcher.h" |
| #include "url/origin.h" |
| |
| using content::RenderFrameHost; |
| using content::WebContents; |
| using optimization_guide::DocumentIdentifierUserData; |
| using optimization_guide::proto::Action; |
| using optimization_guide::proto::Actions; |
| using optimization_guide::proto::ActionTarget; |
| using optimization_guide::proto::AnnotatedPageContent; |
| using optimization_guide::proto::BrowserAction; |
| using tabs::TabInterface; |
| |
| namespace actor { |
| |
| namespace { |
| |
| void PostTaskForActCallback( |
| ActorTask::ActCallback callback, |
| mojom::ActionResultPtr result, |
| std::optional<size_t> index_of_failed_action, |
| std::vector<optimization_guide::proto::ScriptToolResult> |
| script_tool_results) { |
| UMA_HISTOGRAM_ENUMERATION("Actor.ExecutionEngine.Action.ResultCode", |
| result->code); |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, |
| base::BindOnce(std::move(callback), std::move(result), |
| index_of_failed_action, std::move(script_tool_results))); |
| } |
| |
| } // namespace |
| |
| ExecutionEngine::ExecutionEngine(Profile* profile) |
| : profile_(profile), |
| journal_(ActorKeyedService::Get(profile)->GetJournal().GetSafeRef()), |
| ui_event_dispatcher_(ui::NewUiEventDispatcher( |
| ActorKeyedService::Get(profile)->GetActorUiStateManager())) { |
| CHECK(profile_); |
| // Idempotent. Enables the action blocklist if it isn't already enabled. |
| InitActionBlocklist(profile_.get()); |
| } |
| |
| ExecutionEngine::ExecutionEngine( |
| Profile* profile, |
| std::unique_ptr<ui::UiEventDispatcher> ui_event_dispatcher) |
| : profile_(profile), |
| journal_(ActorKeyedService::Get(profile)->GetJournal().GetSafeRef()), |
| ui_event_dispatcher_(std::move(ui_event_dispatcher)) { |
| CHECK(profile_); |
| // Idempotent. Enables the action blocklist if it isn't already enabled. |
| InitActionBlocklist(profile_.get()); |
| } |
| |
| std::unique_ptr<ExecutionEngine> ExecutionEngine::CreateForTesting( |
| Profile* profile, |
| std::unique_ptr<ui::UiEventDispatcher> ui_event_dispatcher) { |
| return base::WrapUnique<ExecutionEngine>( |
| new ExecutionEngine(profile, std::move(ui_event_dispatcher))); |
| } |
| |
| ExecutionEngine::~ExecutionEngine() { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| } |
| |
| void ExecutionEngine::SetOwner(ActorTask* task) { |
| task_ = task; |
| actor_login_service_ = std::make_unique<actor_login::ActorLoginServiceImpl>(); |
| tool_controller_ = std::make_unique<ToolController>(*task_, *this); |
| } |
| |
| void ExecutionEngine::SetState(State state) { |
| journal_->Log(GURL(), task_->id(), mojom::JournalTrack::kActor, |
| "ExecutionEngine::StateChange", |
| absl::StrFormat("State %s -> %s", StateToString(state_), |
| StateToString(state))); |
| |
| #if DCHECK_IS_ON() |
| static const base::NoDestructor<base::StateTransitions<State>> transitions( |
| base::StateTransitions<State>({ |
| {State::kInit, {State::kStartAction, State::kComplete}}, |
| {State::kStartAction, |
| {State::kToolCreateAndVerify, State::kComplete}}, |
| {State::kToolCreateAndVerify, |
| {State::kUiPreInvoke, State::kComplete}}, |
| {State::kUiPreInvoke, {State::kToolInvoke, State::kComplete}}, |
| {State::kToolInvoke, {State::kUiPostInvoke, State::kComplete}}, |
| {State::kUiPostInvoke, {State::kComplete, State::kStartAction}}, |
| {State::kComplete, {State::kStartAction}}, |
| })); |
| DCHECK_STATE_TRANSITION(transitions, state_, state); |
| #endif // DCHECK_IS_ON() |
| state_ = state; |
| } |
| |
| std::string ExecutionEngine::StateToString(State state) { |
| switch (state) { |
| case State::kInit: |
| return "INIT"; |
| case State::kStartAction: |
| return "START_ACTION"; |
| case State::kToolCreateAndVerify: |
| return "CREATE_AND_VERIFY"; |
| case State::kUiPreInvoke: |
| return "UI_PRE_INVOKE"; |
| case State::kToolInvoke: |
| return "TOOL_INVOKE"; |
| case State::kUiPostInvoke: |
| return "UI_POST_INVOKE"; |
| case State::kComplete: |
| return "COMPLETE"; |
| } |
| } |
| |
| void ExecutionEngine::RegisterWithProfile(Profile* profile) { |
| InitActionBlocklist(profile); |
| } |
| |
| void ExecutionEngine::CancelOngoingActions(mojom::ActionResultCode reason) { |
| if (!action_sequence_.empty()) { |
| CompleteActions(MakeResult(reason), /*action_index=*/std::nullopt); |
| } |
| } |
| |
| void ExecutionEngine::FailCurrentTool(mojom::ActionResultCode reason) { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| CHECK_NE(reason, mojom::ActionResultCode::kOk); |
| if (state_ != State::kToolInvoke) { |
| return; |
| } |
| |
| external_tool_failure_reason_ = reason; |
| } |
| |
| void ExecutionEngine::Act(std::vector<std::unique_ptr<ToolRequest>>&& actions, |
| ActorTask::ActCallback callback) { |
| CHECK(base::FeatureList::IsEnabled(features::kGlicActor)); |
| CHECK(!actions.empty()); |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| CHECK_EQ(task_->GetState(), ActorTask::State::kActing); |
| |
| if (!action_sequence_.empty()) { |
| journal_->Log( |
| actions[0]->GetURLForJournal(), task_->id(), |
| mojom::JournalTrack::kActor, "Act Failed", |
| "Unable to perform action: task already has action in progress"); |
| PostTaskForActCallback(std::move(callback), |
| MakeResult(mojom::ActionResultCode::kError, |
| "Task already has action in progress"), |
| std::nullopt, {}); |
| return; |
| } |
| |
| act_callback_ = std::move(callback); |
| next_action_index_ = 0; |
| |
| absl::flat_hash_set<int32_t> acting_tab_handles; |
| |
| action_sequence_ = std::move(actions); |
| for (const std::unique_ptr<ToolRequest>& action : action_sequence_) { |
| CHECK(action); |
| if (action->GetTabHandle() != tabs::TabHandle::Null()) { |
| acting_tab_handles.insert(action->GetTabHandle().raw_value()); |
| } |
| } |
| |
| if (state_ == State::kInit) { |
| // This is the first Act() by this ExecutionEngine, so we should notify |
| // the UI, then kickoff the first action. |
| // |
| // TODO(crbug.com/411462297): Make sure we're property dispatching |
| // StartingToActOnTab UiEvents when tasks aren't scoped to a single tab. |
| // This won't work if the first action sequence is creating the tab on which |
| // following sequences will act. |
| // TODO(crbug.com/420669167): This needs to support taking multiple tabs. Is |
| // it even the right interface? Different sets of tabs might be acted on in |
| // followup sequences... |
| ui_event_dispatcher_->OnPreFirstAct( |
| ui::UiEventDispatcher::FirstActInfo{ |
| .task_id = task_->id(), |
| .tab_handle = acting_tab_handles.empty() |
| ? std::nullopt |
| : std::make_optional(tabs::TabHandle( |
| *acting_tab_handles.begin()))}, |
| base::BindOnce(&ExecutionEngine::KickOffNextAction, GetWeakPtr())); |
| } else { |
| // We previously notified the UI, so just kickoff the first action. |
| base::SequencedTaskRunner::GetCurrentDefault()->PostTask( |
| FROM_HERE, base::BindOnce(&ExecutionEngine::KickOffNextAction, |
| GetWeakPtr(), MakeOkResult())); |
| } |
| } |
| |
| void ExecutionEngine::KickOffNextAction( |
| mojom::ActionResultPtr init_hooks_result) { |
| DCHECK(state_ == State::kInit || state_ == State::kUiPostInvoke || |
| state_ == State::kComplete) |
| << "Current state is " << StateToString(state_); |
| CHECK_LT(next_action_index_, action_sequence_.size()); |
| |
| // The init hooks errored out. |
| if (init_hooks_result && !IsOk(*init_hooks_result)) { |
| CompleteActions(std::move(init_hooks_result), |
| /*action_index=*/std::nullopt); |
| return; |
| } |
| |
| SetState(State::kStartAction); |
| |
| // TODO(crbug.com/411462297): It's not clear that navigate requests (which are |
| // tab scoped) should be doing tab safety checks. For now we return `true` to |
| // preserve existing behavior. |
| if (GetNextAction().IsTabScoped()) { |
| SafetyChecksForNextAction(); |
| } else { |
| ExecuteNextAction(); |
| } |
| } |
| |
| void ExecutionEngine::SafetyChecksForNextAction() { |
| tabs::TabInterface* tab = GetNextAction().GetTabHandle().Get(); |
| |
| if (!tab) { |
| journal_->Log(GURL::EmptyGURL(), task_->id(), mojom::JournalTrack::kActor, |
| "Act Failed", "The tab is no longer present"); |
| CompleteActions(MakeResult(mojom::ActionResultCode::kTabWentAway, |
| "The tab is no longer present."), |
| next_action_index_); |
| return; |
| } |
| |
| // Asynchronously check if we can act on the tab. |
| MayActOnTab( |
| *tab, *journal_, task_->id(), |
| base::BindOnce( |
| &ExecutionEngine::DidFinishAsyncSafetyChecks, GetWeakPtr(), |
| tab->GetContents()->GetPrimaryMainFrame()->GetLastCommittedOrigin())); |
| } |
| |
| void ExecutionEngine::DidFinishAsyncSafetyChecks( |
| const url::Origin& evaluated_origin, |
| bool may_act) { |
| DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); |
| CHECK(!action_sequence_.empty()); |
| |
| tabs::TabInterface* tab = GetNextAction().GetTabHandle().Get(); |
| if (!tab) { |
| journal_->Log(GURL::EmptyGURL(), task_->id(), mojom::JournalTrack::kActor, |
| "Act Failed", "The tab is no longer present"); |
| CompleteActions(MakeResult(mojom::ActionResultCode::kTabWentAway, |
| "The tab is no longer present."), |
| next_action_index_); |
| return; |
| } |
| |
| TaskId task_id = task_->id(); |
| if (!evaluated_origin.IsSameOriginWith(tab->GetContents() |
| ->GetPrimaryMainFrame() |
| ->GetLastCommittedOrigin())) { |
| // A cross-origin navigation occurred before we got permission. The result |
| // is no longer applicable. For now just fail. |
| // TODO(mcnee): Handle this gracefully. |
| journal_->Log(GetNextAction().GetURLForJournal(), task_id, |
| mojom::JournalTrack::kActor, "Act Failed", |
| "Acting after cross-origin navigation occurred"); |
| CompleteActions(MakeResult(mojom::ActionResultCode::kCrossOriginNavigation, |
| "Acting after cross-origin navigation occurred"), |
| next_action_index_); |
| return; |
| } |
| |
| if (!may_act) { |
| journal_->Log(GetNextAction().GetURLForJournal(), task_id, |
| mojom::JournalTrack::kActor, "Act Failed", |
| "URL blocked for actions"); |
| CompleteActions(MakeResult(mojom::ActionResultCode::kUrlBlocked, |
| "URL blocked for actions"), |
| next_action_index_); |
| return; |
| } |
| |
| ExecuteNextAction(); |
| } |
| |
| void ExecutionEngine::ExecuteNextAction() { |
| DCHECK_EQ(state_, State::kStartAction); |
| CHECK(!action_sequence_.empty()); |
| CHECK(tool_controller_); |
| |
| ++next_action_index_; |
| |
| SetState(State::kToolCreateAndVerify); |
| tool_controller_->CreateToolAndValidate( |
| GetInProgressAction(), |
| base::BindOnce(&ExecutionEngine::PostToolCreate, GetWeakPtr())); |
| } |
| |
| void ExecutionEngine::PostToolCreate(mojom::ActionResultPtr result) { |
| if (!IsOk(*result)) { |
| CompleteActions(std::move(result), InProgressActionIndex()); |
| return; |
| } |
| SetState(State::kUiPreInvoke); |
| ui_event_dispatcher_->OnPreTool( |
| GetInProgressAction(), |
| base::BindOnce(&ExecutionEngine::FinishedUiPreInvoke, GetWeakPtr())); |
| } |
| |
| void ExecutionEngine::FinishedUiPreInvoke(mojom::ActionResultPtr result) { |
| DCHECK_EQ(state_, State::kUiPreInvoke); |
| if (!IsOk(*result)) { |
| CompleteActions(std::move(result), InProgressActionIndex()); |
| return; |
| } |
| |
| SetState(State::kToolInvoke); |
| tool_controller_->Invoke( |
| base::BindOnce(&ExecutionEngine::FinishedToolInvoke, GetWeakPtr())); |
| } |
| |
| void ExecutionEngine::FinishedToolInvoke(mojom::ActionResultPtr result) { |
| DCHECK_EQ(state_, State::kToolInvoke); |
| // The current action errored out. Stop the chain. |
| std::optional<mojom::ActionResultCode> external_tool_failure_reason; |
| std::swap(external_tool_failure_reason, external_tool_failure_reason_); |
| if (external_tool_failure_reason) { |
| CompleteActions(MakeResult(*external_tool_failure_reason), |
| InProgressActionIndex()); |
| return; |
| } |
| if (!IsOk(*result)) { |
| CompleteActions(std::move(result), InProgressActionIndex()); |
| return; |
| } |
| |
| if (result->script_tool_response) { |
| auto& script_tool_result = script_tool_results_.emplace_back(); |
| script_tool_result.set_index_of_script_tool_action(InProgressActionIndex()); |
| script_tool_result.set_result(*result->script_tool_response); |
| } |
| |
| SetState(State::kUiPostInvoke); |
| ui_event_dispatcher_->OnPostTool( |
| GetInProgressAction(), |
| base::BindOnce(&ExecutionEngine::FinishedUiPostInvoke, GetWeakPtr())); |
| } |
| |
| void ExecutionEngine::FinishedUiPostInvoke(mojom::ActionResultPtr result) { |
| DCHECK_EQ(state_, State::kUiPostInvoke); |
| CHECK(!action_sequence_.empty()); |
| |
| if (!IsOk(*result)) { |
| CompleteActions(std::move(result), InProgressActionIndex()); |
| return; |
| } |
| |
| if (next_action_index_ >= action_sequence_.size()) { |
| CompleteActions(MakeOkResult(), std::nullopt); |
| return; |
| } |
| |
| KickOffNextAction(/*init_hooks_result=*/nullptr); |
| } |
| |
| void ExecutionEngine::CompleteActions(mojom::ActionResultPtr result, |
| std::optional<size_t> action_index) { |
| CHECK(!action_sequence_.empty()); |
| CHECK(act_callback_); |
| |
| SetState(State::kComplete); |
| |
| if (!IsOk(*result)) { |
| GURL url; |
| if (action_index) { |
| url = action_sequence_[*action_index]->GetURLForJournal(); |
| } |
| journal_->Log(url, task_->id(), mojom::JournalTrack::kActor, "Act Failed", |
| ToDebugString(*result)); |
| } |
| |
| // TODO(crbug.com/411462297): Populate observation. |
| PostTaskForActCallback(std::move(act_callback_), std::move(result), |
| action_index, std::move(script_tool_results_)); |
| |
| action_sequence_.clear(); |
| next_action_index_ = 0; |
| actions_weak_ptr_factory_.InvalidateWeakPtrs(); |
| } |
| |
| base::WeakPtr<ExecutionEngine> ExecutionEngine::GetWeakPtr() { |
| return actions_weak_ptr_factory_.GetWeakPtr(); |
| } |
| |
| AggregatedJournal& ExecutionEngine::GetJournal() { |
| return *journal_; |
| } |
| |
| actor_login::ActorLoginService& ExecutionEngine::GetActorLoginService() { |
| return *actor_login_service_; |
| } |
| |
| void ExecutionEngine::PromptToSelectCredential( |
| const std::vector<actor_login::Credential>& credentials, |
| ToolDelegate::CredentialSelectedCallback callback) { |
| CHECK(!credentials.empty()); |
| // TODO(crbug.com/427817882): Wire this up to the WebClient. |
| std::move(callback).Run(credentials.front()); |
| } |
| |
| const ToolRequest& ExecutionEngine::GetNextAction() const { |
| CHECK_LT(next_action_index_, action_sequence_.size()); |
| return *action_sequence_.at(next_action_index_).get(); |
| } |
| |
| size_t ExecutionEngine::InProgressActionIndex() const { |
| CHECK(state_ == State::kUiPreInvoke || state_ == State::kToolInvoke || |
| state_ == State::kUiPostInvoke || state_ == State::kToolCreateAndVerify) |
| << "Current state is " << StateToString(state_); |
| CHECK_GT(next_action_index_, 0ul); |
| return next_action_index_ - 1; |
| } |
| |
| const ToolRequest& ExecutionEngine::GetInProgressAction() const { |
| return *action_sequence_.at(InProgressActionIndex()).get(); |
| } |
| |
| std::ostream& operator<<(std::ostream& o, const ExecutionEngine::State& s) { |
| return o << ExecutionEngine::StateToString(s); |
| } |
| |
| } // namespace actor |