mobius 0.16.1

A small, modular Rust framework for building coding agents
Documentation
use super::*;

struct PanickingTool;

struct ApprovalRequiredTool;

struct UsageTool;

impl Tool for UsageTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "usage_tool".into(),
            description: "Reports paid usage".into(),
            parameters: serde_json::json!({"type": "object", "properties": {}}),
        }
    }

    fn exposure(&self) -> ToolExposure {
        ToolExposure::Direct
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        Box::pin(async move {
            assert_eq!(
                context.call_id, "paid",
                "dispatch retains the model's call identity"
            );
            context.report_usage(crate::protocol::TokenUsage {
                total_tokens: 7,
                ..Default::default()
            })?;
            Err(Error::Tool(
                "publication failed after provider billing".into(),
            ))
        })
    }
}

impl Tool for PanickingTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "panicking".into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Parallel
    }

    fn call<'a>(
        &'a self,
        _context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        panic!("intentional tool panic")
    }
}

impl Tool for ApprovalRequiredTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "approval_required".into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn approval(&self) -> ApprovalRequirement {
        ApprovalRequirement::Always
    }

    fn call<'a>(
        &'a self,
        _context: ToolContext,
        _arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        Box::pin(async { Ok("executed".into()) })
    }
}

struct PermissionEchoTool;

#[tokio::test]
async fn paid_tool_usage_retains_the_execution_route() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(UsageTool))
        .expect("register tool");
    let calls = finalize_and_bind(
        &mut catalog,
        &[ToolCall {
            call_id: "paid".into(),
            name: "usage_tool".into(),
            arguments: serde_json::json!({}),
        }],
    );
    let result = execute_batch(
        &catalog,
        &calls,
        test_sandbox(),
        &test_permissions(&[]),
        "turn",
        "image-route",
        &crate::protocol::MessageAuthor::User,
    )
    .await
    .remove(0);
    assert!(result.is_error);
    let (route, usage) = result.usage.expect("usage");
    assert_eq!(route, "image-route");
    assert_eq!(usage.total_tokens, 7);
}

impl Tool for PermissionEchoTool {
    fn definition(&self) -> ToolDefinition {
        ToolDefinition {
            name: "permission_echo".into(),
            description: String::new(),
            parameters: serde_json::json!({}),
        }
    }

    fn execution_mode(&self) -> ExecutionMode {
        ExecutionMode::Parallel
    }

    fn call<'a>(
        &'a self,
        context: ToolContext,
        arguments: Value,
    ) -> BoxFuture<'a, Result<crate::protocol::ToolResponse>> {
        let label = arguments["label"]
            .as_str()
            .expect("permission echo label")
            .to_string();
        Box::pin(
            async move { Ok(format!("{label}:{}", context.permissions.allows_mutation()).into()) },
        )
    }
}

#[tokio::test]
async fn each_call_receives_its_own_permissions() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(PermissionEchoTool))
        .expect("register permission echo tool");
    let calls = [
        ToolCall {
            call_id: "denied".into(),
            name: "permission_echo".into(),
            arguments: serde_json::json!({"label": "first"}),
        },
        ToolCall {
            call_id: "allowed".into(),
            name: "permission_echo".into(),
            arguments: serde_json::json!({"label": "second"}),
        },
    ];
    let calls = finalize_and_bind(&mut catalog, &calls);

    assert_eq!(
        execute_batch(
            &catalog,
            &calls,
            test_sandbox(),
            &test_permissions(&["allowed"]),
            "turn",
            "test",
            &crate::protocol::MessageAuthor::User
        )
        .await,
        vec![
            ToolResult {
                call_id: "denied".into(),
                name: "permission_echo".into(),
                output: "first:false".into(),
                is_error: false,
                usage: None,
                handler_executed: true,
                additional_input: Vec::new(),
                events: Vec::new(),
            },
            ToolResult {
                call_id: "allowed".into(),
                name: "permission_echo".into(),
                output: "second:true".into(),
                is_error: false,
                usage: None,
                handler_executed: true,
                additional_input: Vec::new(),
                events: Vec::new(),
            },
        ]
    );
}

#[tokio::test]
async fn parallel_tool_panic_preserves_call_identity() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(PanickingTool))
        .expect("register tool");
    let calls = [ToolCall {
        call_id: "call-1".into(),
        name: "panicking".into(),
        arguments: serde_json::json!({}),
    }];
    let calls = finalize_and_bind(&mut catalog, &calls);
    let backend =
        Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("local sandbox"));
    let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
        backend,
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let permissions = SandboxPermissions::restore(
        "session",
        crate::backend::sandbox::SandboxMode::WorkspaceWrite,
        crate::backend::sandbox::NetworkAccess::Denied,
        Vec::new(),
    );

    assert_eq!(
        execute_batch(
            &catalog,
            &calls,
            sandbox,
            &permissions,
            "turn",
            "test",
            &crate::protocol::MessageAuthor::User
        )
        .await,
        vec![ToolResult {
            call_id: "call-1".into(),
            name: "panicking".into(),
            output: "tool panicked".into(),
            is_error: true,
            usage: None,
            handler_executed: true,
            additional_input: Vec::new(),
            events: Vec::new(),
        }]
    );
}

#[tokio::test]
async fn approval_required_handler_cannot_run_without_exact_call_authority() {
    let mut catalog = Catalog::default();
    catalog
        .register(Arc::new(ApprovalRequiredTool))
        .expect("register tool");
    let calls = [ToolCall {
        call_id: "blocked".into(),
        name: "approval_required".into(),
        arguments: serde_json::json!({}),
    }];
    let calls = finalize_and_bind(&mut catalog, &calls);
    let sandbox = Arc::new(crate::backend::sandbox::Sandbox::new(
        Arc::new(crate::backend::sandbox::local::LocalSandbox::new(".").expect("sandbox")),
        crate::backend::sandbox::ApprovalPolicy::Ask,
    ));
    let permissions = SandboxPermissions::restore(
        "session",
        crate::backend::sandbox::SandboxMode::WorkspaceWrite,
        crate::backend::sandbox::NetworkAccess::Allowed,
        ["different-call".into()],
    );

    let result = execute_batch(
        &catalog,
        &calls,
        sandbox,
        &permissions,
        "turn",
        "test",
        &crate::protocol::MessageAuthor::User,
    )
    .await
    .pop()
    .expect("tool result");

    assert_eq!(
        (
            result.is_error,
            result.handler_executed,
            result.output.text().as_str()
        ),
        (true, false, "tool call is not authorized to mutate state")
    );
}