starweaver-cli 0.9.0

Command-line interface for Starweaver
Documentation
use std::collections::BTreeSet;

use starweaver_model::{ModelMessage, ModelRequestPart, ModelResponsePart, ToolReturnPart};
use starweaver_session::{ApprovalRecord, ApprovalStatus, DeferredToolRecord, ExecutionStatus};

use crate::{CliError, CliResult};

pub(super) fn existing_resume_tool_return_ids(history: &[ModelMessage]) -> BTreeSet<String> {
    let Some(last_tool_response_index) = history.iter().rposition(|message| match message {
        ModelMessage::Response(response) => response
            .parts
            .iter()
            .any(|part| matches!(part, ModelResponsePart::ToolCall(_))),
        ModelMessage::Request(_) => false,
    }) else {
        return BTreeSet::new();
    };
    history
        .iter()
        .skip(last_tool_response_index.saturating_add(1))
        .filter_map(|message| match message {
            ModelMessage::Request(request) => Some(&request.parts),
            ModelMessage::Response(_) => None,
        })
        .flat_map(|parts| parts.iter())
        .filter_map(|part| match part {
            ModelRequestPart::ToolReturn(tool_return) => Some(tool_return.tool_call_id.clone()),
            _ => None,
        })
        .collect()
}

pub(super) fn latest_tool_call_order(history: &[ModelMessage]) -> Vec<String> {
    history
        .iter()
        .rev()
        .find_map(|message| match message {
            ModelMessage::Response(response) => Some(
                response
                    .parts
                    .iter()
                    .filter_map(|part| match part {
                        ModelResponsePart::ToolCall(call) => Some(call.id.clone()),
                        _ => None,
                    })
                    .collect::<Vec<_>>(),
            ),
            ModelMessage::Request(_) => None,
        })
        .unwrap_or_default()
}

pub(super) fn tool_return_control_flow(tool_return: &ToolReturnPart) -> Option<&str> {
    tool_return
        .metadata
        .get("control_flow")
        .and_then(serde_json::Value::as_str)
}

pub(super) const fn deferred_status_is_unresolved(status: ExecutionStatus) -> bool {
    matches!(
        status,
        ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Waiting
    )
}

pub(super) fn pending_hitl_resume_error(
    run_id: &str,
    pending_approvals: &[String],
    pending_deferred: &[String],
) -> CliError {
    let mut details = Vec::new();
    if !pending_approvals.is_empty() {
        details.push(format!("approvals={}", pending_approvals.join(",")));
    }
    if !pending_deferred.is_empty() {
        details.push(format!("deferred_tools={}", pending_deferred.join(",")));
    }
    CliError::Usage(format!(
        "cannot resume run {run_id}: pending HITL decisions remain ({})",
        details.join("; ")
    ))
}

pub(super) fn approval_tool_return(record: &ApprovalRecord) -> CliResult<Option<ToolReturnPart>> {
    let decision = record.decision.as_ref();
    let mut metadata = serde_json::Map::new();
    metadata.insert(
        "control_flow_resolution".to_string(),
        serde_json::json!("approval"),
    );
    metadata.insert(
        "approval_id".to_string(),
        serde_json::json!(record.approval_id),
    );
    metadata.insert(
        "approval_status".to_string(),
        serde_json::json!(record.status),
    );
    if let Some(decision) = decision {
        metadata.insert("decision".to_string(), serde_json::json!(decision));
    }
    Ok(match record.status {
        ApprovalStatus::Approved => {
            let reason = decision.and_then(|decision| decision.reason.as_ref());
            let is_clarifying = record.action_name == starweaver_agent::ASK_USER_QUESTION_TOOL_NAME
                && record
                    .request
                    .get("kind")
                    .and_then(serde_json::Value::as_str)
                    == Some(starweaver_agent::CLARIFYING_QUESTIONS_REQUEST_KIND);
            let mut content = if is_clarifying {
                let questions = record.request.get("questions").cloned().unwrap_or_default();
                let user_input = reason.map_or_else(
                    || serde_json::Value::String(String::new()),
                    |reason| {
                        serde_json::from_str(reason)
                            .unwrap_or_else(|_| serde_json::Value::String(reason.clone()))
                    },
                );
                let result =
                    starweaver_agent::resolve_clarifying_question_answers(questions, user_input)
                        .map_err(|error| CliError::Run(error.to_string()))?;
                serde_json::to_value(result).map_err(|error| CliError::Run(error.to_string()))?
            } else {
                serde_json::json!({
                    "approved": true,
                    "approval_id": record.approval_id,
                    "tool_name": record.action_name,
                    "request": record.request,
                })
            };
            if !is_clarifying && let Some(reason) = reason {
                content["reason"] = serde_json::json!(reason);
            }
            Some(
                ToolReturnPart::new(
                    record.action_id.clone(),
                    record.action_name.clone(),
                    content,
                )
                .with_metadata(metadata),
            )
        }
        ApprovalStatus::Denied | ApprovalStatus::Expired | ApprovalStatus::Cancelled => {
            let reason = decision
                .and_then(|decision| decision.reason.clone())
                .unwrap_or_else(|| format!("approval {}", approval_status_name(record.status)));
            Some(
                ToolReturnPart::new(
                    record.action_id.clone(),
                    record.action_name.clone(),
                    serde_json::json!({
                        "approved": false,
                        "approval_id": record.approval_id,
                        "tool_name": record.action_name,
                        "reason": reason,
                    }),
                )
                .with_error(true)
                .with_metadata(metadata),
            )
        }
        ApprovalStatus::Pending => None,
    })
}

pub(super) fn deferred_tool_return(record: &DeferredToolRecord) -> Option<ToolReturnPart> {
    let mut metadata = serde_json::Map::new();
    metadata.insert(
        "control_flow_resolution".to_string(),
        serde_json::json!("deferred"),
    );
    metadata.insert(
        "deferred_id".to_string(),
        serde_json::json!(record.deferred_id),
    );
    metadata.insert(
        "deferred_status".to_string(),
        serde_json::json!(record.status),
    );
    match record.status {
        ExecutionStatus::Completed => Some(
            ToolReturnPart::new(
                record.tool_call_id.clone(),
                record.tool_name.clone(),
                record.response.clone(),
            )
            .with_metadata(metadata),
        ),
        ExecutionStatus::Failed | ExecutionStatus::Cancelled => Some(
            ToolReturnPart::new(
                record.tool_call_id.clone(),
                record.tool_name.clone(),
                if record.response.is_null() {
                    serde_json::json!({"error": deferred_status_name(record.status)})
                } else {
                    record.response.clone()
                },
            )
            .with_error(true)
            .with_metadata(metadata),
        ),
        ExecutionStatus::Pending | ExecutionStatus::Running | ExecutionStatus::Waiting => None,
    }
}

const fn approval_status_name(status: ApprovalStatus) -> &'static str {
    match status {
        ApprovalStatus::Pending => "pending",
        ApprovalStatus::Approved => "approved",
        ApprovalStatus::Denied => "denied",
        ApprovalStatus::Expired => "expired",
        ApprovalStatus::Cancelled => "cancelled",
    }
}

const fn deferred_status_name(status: ExecutionStatus) -> &'static str {
    match status {
        ExecutionStatus::Pending => "pending",
        ExecutionStatus::Running => "running",
        ExecutionStatus::Waiting => "waiting",
        ExecutionStatus::Completed => "completed",
        ExecutionStatus::Failed => "failed",
        ExecutionStatus::Cancelled => "cancelled",
    }
}

#[cfg(test)]
mod tests {
    use chrono::Utc;
    use starweaver_core::{RunId, SessionId};
    use starweaver_session::{ApprovalDecision, ApprovalRecord, ApprovalStatus};

    use super::approval_tool_return;

    #[test]
    fn clarifying_approval_returns_questions_and_free_form_response() {
        let mut record = ApprovalRecord::new(
            "approval_question",
            SessionId::from_string("session_question"),
            RunId::from_string("run_question"),
            "call_question",
            starweaver_agent::ASK_USER_QUESTION_TOOL_NAME,
        );
        record.request = serde_json::json!({
            "kind": starweaver_agent::CLARIFYING_QUESTIONS_REQUEST_KIND,
            "questions": [{
                "question": "Which database?",
                "header": "Database",
                "options": [
                    {"label": "PostgreSQL", "description": "Use PostgreSQL"},
                    {"label": "SQLite", "description": "Use SQLite"}
                ],
                "multiSelect": false
            }]
        });
        record.status = ApprovalStatus::Approved;
        record.decision = Some(ApprovalDecision {
            status: ApprovalStatus::Approved,
            decided_by: Some("starweaver-cli".to_string()),
            decided_at: Utc::now(),
            reason: Some("PostgreSQL".to_string()),
            metadata: serde_json::Map::default(),
        });

        let Ok(Some(tool_return)) = approval_tool_return(&record) else {
            panic!("approved clarifying request must produce a tool return");
        };

        assert!(!tool_return.is_error);
        assert_eq!(tool_return.content["response"], "PostgreSQL");
        assert_eq!(
            tool_return.content["questions"][0]["question"],
            "Which database?"
        );
        assert!(tool_return.content.get("approved").is_none());
    }
}