roboticus-api 0.11.3

HTTP routes, WebSocket, auth, rate limiting, and dashboard for the Roboticus agent runtime
Documentation
use super::*;
use async_trait::async_trait;
use roboticus_agent::capability::{Capability, CapabilitySource};
use roboticus_agent::tools::{ToolContext, ToolError, ToolResult};
use roboticus_core::RiskLevel;
use serde_json::json;
use std::sync::Arc;

fn inference_input<'a>(
    state: &'a AppState,
    session_id: &'a str,
    turn_id: &'a str,
    user_content: &'a str,
) -> super::core::InferenceInput<'a> {
    super::core::InferenceInput {
        state,
        session_id,
        user_content,
        turn_id,
        channel_label: "api",
        agent_name: "TestBot".to_string(),
        agent_id: "test-agent".to_string(),
        os_text: String::new(),
        firmware_text: String::new(),
        primary_model: "ollama/qwen3:8b".to_string(),
        tier_adapt: roboticus_core::config::TierAdaptConfig::default(),
        delegation_workflow_note: None,
        inject_diagnostics: false,
        gate_system_note: None,
        delegated_execution_note: None,
        delegated_execution_result: None,
        behavioral_note: None,
        content_parts: None,
        topic_tag: None,
    }
}

#[tokio::test]
async fn prepare_inference_autonomously_includes_working_memory_without_explicit_recall_prompt() {
    let state = crate::api::routes::tests::test_state();
    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "memory-auto-working", None).unwrap();
    let user_prompt = "Draft a concise status update for this week.";
    let turn_id =
        roboticus_db::sessions::create_turn(&state.db, &session_id, None, None, None, None)
            .unwrap();
    roboticus_db::sessions::append_message(&state.db, &session_id, "user", user_prompt).unwrap();
    roboticus_db::memory::store_working(
        &state.db,
        &session_id,
        "goal",
        "Prioritize telegram stability and avoid duplicate replies",
        9,
    )
    .unwrap();

    let prepared = super::core::prepare_inference(&inference_input(
        &state,
        &session_id,
        &turn_id,
        user_prompt,
    ))
    .await
    .unwrap();

    assert!(
        prepared.context_snapshot.memory_tokens > 0,
        "memory token bucket should be non-zero when memory is auto-injected"
    );
    // Working memory is directly injected (not index-only)
    let memory_block = prepared
        .request
        .messages
        .iter()
        .find(|m| m.role == "system" && m.content.contains("[Working Memory]"))
        .map(|m| m.content.clone())
        .expect("prepared context should contain a directly-injected [Working Memory] block");
    assert!(memory_block.contains("telegram stability"));
}

#[tokio::test]
async fn prepare_inference_autonomously_uses_relevant_semantic_memories() {
    let state = crate::api::routes::tests::test_state();
    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "memory-auto-relevant", None).unwrap();
    let user_prompt = "Give me a short post-incident update with the SLO target.";
    let turn_id =
        roboticus_db::sessions::create_turn(&state.db, &session_id, None, None, None, None)
            .unwrap();
    roboticus_db::sessions::append_message(&state.db, &session_id, "user", user_prompt).unwrap();
    let sem_id = roboticus_db::memory::store_semantic(
        &state.db,
        "facts",
        "slo_target",
        "Service-level objective target is 99.95% for core APIs",
        0.9,
    )
    .unwrap();
    // Index entry mirrors production write path — semantic memories are index-only
    roboticus_db::memory_index::upsert_index_entry(
        &state.db,
        "semantic_memory",
        &sem_id,
        "SLO target 99.95% for core APIs",
        Some("facts"),
    )
    .unwrap();

    let prepared = super::core::prepare_inference(&inference_input(
        &state,
        &session_id,
        &turn_id,
        user_prompt,
    ))
    .await
    .unwrap();

    let memory_block = prepared
        .request
        .messages
        .iter()
        .find(|m| m.role == "system" && m.content.contains("[Memory Index"))
        .map(|m| m.content.to_ascii_lowercase())
        .expect("prepared context should contain a [Memory Index block for semantic memories");
    assert!(
        memory_block.contains("99.95"),
        "index entry summary should contain the SLO target"
    );
}

#[tokio::test]
async fn prepare_inference_autonomously_uses_relevant_episodic_memories() {
    let state = crate::api::routes::tests::test_state();
    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "memory-auto-episodic", None).unwrap();
    let user_prompt = "Give me a short update on incident stabilization and rollback status.";
    let turn_id =
        roboticus_db::sessions::create_turn(&state.db, &session_id, None, None, None, None)
            .unwrap();
    roboticus_db::sessions::append_message(&state.db, &session_id, "user", user_prompt).unwrap();
    let ep_id = roboticus_db::memory::store_episodic(
        &state.db,
        "incident",
        "Incident bridge stabilized after rollback and mitigation",
        8,
    )
    .unwrap();
    // Index entry mirrors production write path — episodic memories are index-only
    roboticus_db::memory_index::upsert_index_entry(
        &state.db,
        "episodic_memory",
        &ep_id,
        "Incident bridge stabilized after rollback",
        Some("incident"),
    )
    .unwrap();

    let prepared = super::core::prepare_inference(&inference_input(
        &state,
        &session_id,
        &turn_id,
        user_prompt,
    ))
    .await
    .unwrap();

    let memory_block = prepared
        .request
        .messages
        .iter()
        .find(|m| m.role == "system" && m.content.contains("[Memory Index"))
        .map(|m| m.content.to_ascii_lowercase())
        .expect("prepared context should contain a [Memory Index block for episodic memories");
    assert!(
        memory_block.contains("incident bridge stabilized"),
        "index entry summary should contain the incident description"
    );
}

#[tokio::test]
async fn prepare_inference_includes_operational_introspection_policy() {
    let state = crate::api::routes::tests::test_state();
    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "memory-introspection-policy", None)
            .unwrap();
    let user_prompt = "Do we already know enough to delegate this migration review?";
    let turn_id =
        roboticus_db::sessions::create_turn(&state.db, &session_id, None, None, None, None)
            .unwrap();
    roboticus_db::sessions::append_message(&state.db, &session_id, "user", user_prompt).unwrap();

    let prepared = super::core::prepare_inference(&inference_input(
        &state,
        &session_id,
        &turn_id,
        user_prompt,
    ))
    .await
    .unwrap();

    let system_prompt = prepared
        .request
        .messages
        .iter()
        .find(|m| m.role == "system" && m.content.contains("Operational Introspection"))
        .map(|m| m.content.clone())
        .expect("prepared inference should include operational introspection guidance");

    assert!(system_prompt.contains("get_memory_stats"));
    assert!(system_prompt.contains("get_runtime_context"));
    assert!(system_prompt.contains("hippocampus-backed"));
    assert!(system_prompt.contains("list-subagent-roster"));
    assert!(system_prompt.contains("compose-skill"));
    assert!(system_prompt.contains("compose-subagent"));
}

struct TestCapability {
    name: String,
    description: String,
}

#[async_trait]
impl Capability for TestCapability {
    fn name(&self) -> &str {
        &self.name
    }

    fn description(&self) -> &str {
        &self.description
    }

    fn risk_level(&self) -> RiskLevel {
        RiskLevel::Safe
    }

    fn parameters_schema(&self) -> serde_json::Value {
        json!({"type": "object"})
    }

    fn source(&self) -> CapabilitySource {
        CapabilitySource::BuiltIn
    }

    async fn execute(
        &self,
        _params: serde_json::Value,
        _ctx: &ToolContext,
    ) -> Result<ToolResult, ToolError> {
        Ok(ToolResult {
            output: "ok".into(),
            metadata: None,
        })
    }
}

#[tokio::test]
async fn prepare_inference_prunes_tool_list_with_tool_search() {
    let state = crate::api::routes::tests::test_state();
    for idx in 0..20 {
        let (name, description) = if idx == 0 {
            (
                "incident_tool".to_string(),
                "Analyze incident rollback status and summarize service recovery".to_string(),
            )
        } else {
            (
                format!("irrelevant_tool_{idx}"),
                format!("Generate botanical poetry about rare orchids variant {idx}"),
            )
        };
        state
            .capabilities
            .register(Arc::new(TestCapability { name, description }))
            .await
            .unwrap();
    }

    let session_id =
        roboticus_db::sessions::find_or_create(&state.db, "tool-search-pruning", None).unwrap();
    let user_prompt = "Give me an incident rollback status update and recovery summary.";
    let turn_id =
        roboticus_db::sessions::create_turn(&state.db, &session_id, None, None, None, None)
            .unwrap();
    roboticus_db::sessions::append_message(&state.db, &session_id, "user", user_prompt).unwrap();

    let prepared = super::core::prepare_inference(&inference_input(
        &state,
        &session_id,
        &turn_id,
        user_prompt,
    ))
    .await
    .unwrap();

    assert!(
        prepared.request.tools.len() <= 15,
        "tool-search should prune tool definitions to top-k, got {}",
        prepared.request.tools.len()
    );
    assert!(
        prepared
            .request
            .tools
            .iter()
            .any(|tool| tool.name == "incident_tool"),
        "relevant tool should survive pruning"
    );
    let stats = prepared
        .tool_search_stats
        .as_ref()
        .expect("tool search stats should be populated when query embedding exists");
    assert!(stats.candidates_considered > prepared.request.tools.len());
    assert!(stats.candidates_pruned > 0);
}