rho-coding-agent 1.4.1

A lightweight agent harness inspired by Pi
Documentation
use crate::{
    app::interactive_presenter::InteractiveToolPresenter,
    questionnaire::{QuestionnaireAnswer, QuestionnaireQuestionKind, QuestionnaireResponse},
    tool::ToolDisplayStyle,
};
use rho_sdk::{
    model::{ContextUsage, ModelUsage},
    HostInputRequest, HostInputResponse, RunEvent, PROVIDER_ACTIVITY_INVALID_RESPONSE_RETRY,
    PROVIDER_ACTIVITY_WEB_SEARCH,
};

use super::questionnaire::{QuestionnaireChoice, QuestionnaireQuestion, QuestionnaireRequest};

#[derive(Clone, Debug)]
pub(super) enum ViewModelEvent {
    RunStarted,
    StepStarted(usize),
    SteeringApplied(Vec<rho_sdk::SteeringId>),
    ToolStarted {
        display_lines: Vec<String>,
    },
    ProviderStreamReset,
    OutputDelta(String),
    ReasoningDelta(String),
    ContextUsage(ContextUsage),
    Usage(ModelUsage),
    ToolUpdated {
        display_lines: Vec<String>,
    },
    ToolCallUpdated {
        display_lines: Vec<String>,
    },
    ToolFinished {
        ok: bool,
        display_style: ToolDisplayStyle,
        display_lines: Vec<String>,
    },
}

#[derive(Clone, Debug)]
pub(super) enum ViewEvent {
    Update(ViewModelEvent),
    Questionnaire(HostInputRequest),
    Notice(String),
    Completed,
    Cancelled,
    Failed(String),
    Ignored,
}

#[derive(Default)]
pub(super) struct SdkEventAdapter {
    presenter: Option<InteractiveToolPresenter>,
}

impl SdkEventAdapter {
    pub(super) fn new(cwd: std::path::PathBuf) -> Self {
        Self {
            presenter: Some(InteractiveToolPresenter::new(cwd)),
        }
    }

    fn presenter(&mut self) -> &mut InteractiveToolPresenter {
        self.presenter
            .get_or_insert_with(|| InteractiveToolPresenter::new(std::path::PathBuf::new()))
    }

    pub(super) fn translate(&mut self, event: RunEvent) -> ViewEvent {
        match event {
            RunEvent::Started { .. } => ViewEvent::Update(ViewModelEvent::RunStarted),
            RunEvent::StepStarted { step } => {
                self.presenter().step_started();
                ViewEvent::Update(ViewModelEvent::StepStarted(step))
            }
            RunEvent::SteeringApplied { ids } => {
                ViewEvent::Update(ViewModelEvent::SteeringApplied(ids))
            }
            RunEvent::AssistantTextDelta { text } => {
                ViewEvent::Update(ViewModelEvent::OutputDelta(text))
            }
            RunEvent::ReasoningDelta { text } | RunEvent::ReasoningSummaryDelta { text } => {
                ViewEvent::Update(ViewModelEvent::ReasoningDelta(text))
            }
            RunEvent::ToolCallUpdated {
                index,
                name,
                arguments_delta,
                ..
            } => self
                .presenter()
                .preview(index, name, &arguments_delta)
                .map_or(ViewEvent::Ignored, |display_lines| {
                    ViewEvent::Update(ViewModelEvent::ToolCallUpdated { display_lines })
                }),
            RunEvent::ToolProposed { call } => {
                let display_lines = self.presenter().proposed(call);
                ViewEvent::Update(ViewModelEvent::ToolCallUpdated { display_lines })
            }
            RunEvent::ToolStarted {
                call_id,
                name,
                metadata,
            } => {
                let display_lines = self
                    .presenter()
                    .started(call_id, name, metadata)
                    .display_lines;
                ViewEvent::Update(ViewModelEvent::ToolStarted { display_lines })
            }
            RunEvent::ToolUpdated { call_id, progress } => {
                let display_lines = self.presenter().updated(&call_id, &progress);
                ViewEvent::Update(ViewModelEvent::ToolUpdated { display_lines })
            }
            RunEvent::ToolFinished { call_id, result } => {
                let (ok, presented) = self.presenter().finished(&call_id, result);
                ViewEvent::Update(ViewModelEvent::ToolFinished {
                    ok,
                    display_style: presented.display_style,
                    display_lines: presented.display_lines,
                })
            }
            RunEvent::UsageUpdated { usage } => ViewEvent::Update(ViewModelEvent::Usage(usage)),
            RunEvent::ProviderActivity { kind, detail } => {
                if kind == PROVIDER_ACTIVITY_INVALID_RESPONSE_RETRY {
                    self.presenter().step_started();
                    ViewEvent::Update(ViewModelEvent::ProviderStreamReset)
                } else if kind == PROVIDER_ACTIVITY_WEB_SEARCH {
                    ViewEvent::Update(ViewModelEvent::ToolFinished {
                        ok: true,
                        display_style: ToolDisplayStyle::web(),
                        display_lines: vec![format!("web search: {detail}")],
                    })
                } else {
                    ViewEvent::Notice(format!("{kind}: {detail}"))
                }
            }
            RunEvent::ProviderContextUpdated { .. } => ViewEvent::Ignored,
            RunEvent::ProviderDiagnostic { detail } => {
                ViewEvent::Notice(format!("provider diagnostic:\n{}", detail.as_str()))
            }
            RunEvent::HostInputRequested { request } => ViewEvent::Questionnaire(request),
            RunEvent::CompactionStarted { .. } => {
                ViewEvent::Notice("compacting conversation context".into())
            }
            RunEvent::CompactionCompleted { outcome, .. } => ViewEvent::Notice(format!(
                "compacted conversation context ({} to {} messages)",
                outcome.previous_messages(),
                outcome.current_messages()
            )),
            RunEvent::Completed { .. } => ViewEvent::Completed,
            RunEvent::Cancelled { .. } => ViewEvent::Cancelled,
            RunEvent::Failed { message, .. } => ViewEvent::Failed(message),
            _ => ViewEvent::Ignored,
        }
    }
}

pub(super) fn questionnaire_request(request: &HostInputRequest) -> QuestionnaireRequest {
    QuestionnaireRequest {
        title: (!request.title().is_empty()).then(|| request.title().to_string()),
        reason: None,
        questions: request
            .questions()
            .iter()
            .map(|question| {
                let choices = question
                    .choices()
                    .iter()
                    .map(|choice| QuestionnaireChoice::new(choice.value(), choice.label()))
                    .collect::<Vec<_>>();
                QuestionnaireQuestion {
                    id: question.id().to_string(),
                    question: question.prompt().to_string(),
                    help: question.help_text().map(str::to_string),
                    default: question.default_value_ref().cloned(),
                    kind: questionnaire_kind(question),
                    required: question.is_required(),
                    choices,
                    allow_other: question.permits_other(),
                }
            })
            .collect(),
    }
}

fn questionnaire_kind(question: &rho_sdk::HostQuestion) -> QuestionnaireQuestionKind {
    match question.selection() {
        rho_sdk::SelectionMode::Many => QuestionnaireQuestionKind::MultiSelect,
        rho_sdk::SelectionMode::One if is_yes_no_question(question) => {
            QuestionnaireQuestionKind::Confirm
        }
        rho_sdk::SelectionMode::One => QuestionnaireQuestionKind::Choice,
        _ => QuestionnaireQuestionKind::Choice,
    }
}

fn is_yes_no_question(question: &rho_sdk::HostQuestion) -> bool {
    matches!(
        question.choices(),
        [yes, no]
            if yes.value().eq_ignore_ascii_case("yes")
                && no.value().eq_ignore_ascii_case("no")
    )
}

pub(super) fn host_response(response: QuestionnaireResponse) -> HostInputResponse {
    response.answers.into_iter().fold(
        HostInputResponse::new(),
        |response, QuestionnaireAnswer { id, answer }| match answer {
            serde_json::Value::Null => response,
            serde_json::Value::Array(values) if values.is_empty() => response,
            serde_json::Value::Array(values) => {
                response.answer(id, values.into_iter().map(answer_text).collect::<Vec<_>>())
            }
            value => response.answer(id, vec![answer_text(value)]),
        },
    )
}

fn answer_text(value: serde_json::Value) -> String {
    match value {
        serde_json::Value::String(value) => value,
        serde_json::Value::Bool(value) => value.to_string(),
        serde_json::Value::Number(value) => value.to_string(),
        value => value.to_string(),
    }
}

#[cfg(test)]
#[path = "event_adapter_tests.rs"]
mod tests;