ras-agent 2.7.0

Agent step loop, history, plan, rerun orchestration
Documentation
use std::sync::Arc;
use std::time::Instant;

use chrono::Utc;
use ras_cdp::BrowserPort;
use ras_dom::DomExtractor;
use ras_errors::AppError;
use ras_events::EventBus;
use ras_llm::{ChatMessage, ChatResponse, InvokeOptions, LlmClient};
use ras_tools::domain::registry::{ActionRegistry, ToolContext};
use ras_types::{ActionResult, StepId};
use url::Url;

use crate::application::compute_action_hash::compute_action_hash;
use crate::application::detect_loop::{build_budget_warning, build_loop_nudge};
use crate::application::fallback_llm::should_switch_to_fallback;
use crate::application::parse_output::parse_agent_output;
use crate::domain::agent_history::StepRecord;
use crate::domain::loop_detector::ActionLoopDetector;
use crate::domain::step_metadata::StepMetadata;

pub struct RunStep {
    primary_llm: Arc<dyn LlmClient>,
    fallback_llm: Option<Arc<dyn LlmClient>>,
    registry: Arc<ActionRegistry>,
    browser: Arc<dyn BrowserPort>,
    events: Arc<dyn EventBus>,
    dom_extractor: Option<Arc<dyn DomExtractor>>,
}

impl RunStep {
    #[must_use]
    pub fn new(
        primary: Arc<dyn LlmClient>,
        fallback: Option<Arc<dyn LlmClient>>,
        registry: Arc<ActionRegistry>,
        browser: Arc<dyn BrowserPort>,
        events: Arc<dyn EventBus>,
        dom_extractor: Option<Arc<dyn DomExtractor>>,
    ) -> Self {
        Self {
            primary_llm: primary,
            fallback_llm: fallback,
            registry,
            browser,
            events,
            dom_extractor,
        }
    }

    pub async fn execute(
        &self,
        step: StepId,
        max_steps: u32,
        prompt: Vec<ChatMessage>,
        detector: &mut ActionLoopDetector,
    ) -> Result<StepRecord, AppError> {
        let started = Instant::now();
        let mut messages = prompt;
        if let Some(nudge) = build_loop_nudge(detector) {
            messages.push(nudge);
        }
        if let Some(warn) = build_budget_warning(step.0, max_steps) {
            messages.push(warn);
        }
        let response = self.invoke_with_fallback(messages).await?;
        let output = parse_agent_output(&response)?;

        let target = self.browser.focused_target().await.ok();
        let page_url = match &target {
            Some(t) => self
                .browser
                .evaluate(t, "location.href")
                .await
                .ok()
                .and_then(|v| v.as_str().and_then(|s| Url::parse(s).ok())),
            None => None,
        };

        let pre_clickables: Arc<Vec<ras_dom::ClickableElement>> =
            match (&self.dom_extractor, &target) {
                (Some(extractor), Some(t)) => extractor
                    .snapshot(t)
                    .await
                    .ok()
                    .map(|s| Arc::new(s.clickables))
                    .unwrap_or_else(|| Arc::new(Vec::new())),
                _ => Arc::new(Vec::new()),
            };

        let mut results = Vec::new();
        for action in &output.action {
            detector.record_action(compute_action_hash(action));
            let Some(reg) = self.registry.get(&action.name) else {
                results.push(ActionResult::err(format!(
                    "unknown action: {}",
                    action.name.0
                )));
                break;
            };
            let ctx = ToolContext {
                browser: self.browser.clone(),
                events: self.events.clone(),
                page_url: page_url.clone(),
                available_files: Vec::new(),
                clickables: pre_clickables.clone(),
            };
            match reg.handler.execute(action.parameters.clone(), ctx).await {
                Ok(r) => {
                    let terminates = reg.metadata.terminates_sequence;
                    let is_done = r.is_done;
                    let is_err = r.is_error();
                    results.push(r);
                    if terminates || is_done || is_err {
                        break;
                    }
                }
                Err(e) => {
                    results.push(ActionResult::err(e.to_string()));
                    break;
                }
            }
        }

        let summary = match (&self.dom_extractor, &target) {
            (Some(extractor), Some(t)) => match extractor.snapshot(t).await {
                Ok(s) => Some(s),
                Err(e) => {
                    tracing::warn!(error = %e, "dom snapshot failed; continuing without grounding");
                    None
                }
            },
            _ => None,
        };

        let metadata = StepMetadata {
            duration_ms: started.elapsed().as_millis() as u64,
            step_interval_ms: None,
            usage: response.usage,
            model: Some(response.model.clone()),
            fallback_used: false,
        };
        Ok(StepRecord {
            step,
            started_at: Utc::now(),
            url: page_url,
            output,
            results,
            metadata,
            summary,
        })
    }

    async fn invoke_with_fallback(
        &self,
        messages: Vec<ChatMessage>,
    ) -> Result<ChatResponse, AppError> {
        let opts = InvokeOptions::default();
        match self
            .primary_llm
            .ainvoke(messages.clone(), opts.clone())
            .await
        {
            Ok(r) => Ok(r),
            Err(e) if should_switch_to_fallback(&e) => match &self.fallback_llm {
                Some(fb) => fb.ainvoke(messages, opts).await,
                None => Err(e),
            },
            Err(e) => Err(e),
        }
    }
}

#[must_use]
pub fn done_result(text: impl Into<String>) -> ActionResult {
    ActionResult::done(text)
}