crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
mod push_assistant_merge_tests {
    use crate::types::{Message, MessageContent, message_content_as_str};

    use super::super::messages::push_assistant_merging_trailing_empty_placeholder;

    fn empty_assistant() -> Message {
        Message {
            role: "assistant".to_string(),
            content: Some(MessageContent::Text(String::new())),
            reasoning_content: None,
            reasoning_details: None,
            tool_calls: None,
            name: None,
            tool_call_id: None,
        }
    }

    fn assistant_body(s: &str) -> Message {
        Message {
            role: "assistant".to_string(),
            content: Some(MessageContent::Text(s.to_string())),
            reasoning_content: None,
            reasoning_details: None,
            tool_calls: None,
            name: None,
            tool_call_id: None,
        }
    }

    #[test]
    fn replaces_trailing_empty_assistant_placeholder() {
        let mut m = vec![Message::user_only("hi"), empty_assistant()];
        push_assistant_merging_trailing_empty_placeholder(&mut m, assistant_body("plan"));
        assert_eq!(m.len(), 2);
        assert_eq!(message_content_as_str(&m[1].content), Some("plan"));
    }

    #[test]
    fn pushes_when_last_assistant_has_content() {
        let mut m = vec![Message::user_only("hi"), assistant_body("first")];
        push_assistant_merging_trailing_empty_placeholder(&mut m, assistant_body("second"));
        assert_eq!(m.len(), 3);
        assert_eq!(message_content_as_str(&m[2].content), Some("second"));
    }
}

mod dedup_tool_calls_tests {
    use crate::types::{FunctionCall, ToolCall};

    use super::super::execute_tools::dedup_readonly_tool_calls_count;

    fn tc(id: &str, name: &str, args: &str) -> ToolCall {
        ToolCall {
            id: id.to_string(),
            typ: "function".to_string(),
            function: FunctionCall {
                name: name.to_string(),
                arguments: args.to_string(),
            },
        }
    }

    #[test]
    fn no_duplicates() {
        let calls = vec![
            tc("1", "read_file", r#"{"path":"a.txt"}"#),
            tc("2", "list_dir", r#"{"path":"."}"#),
        ];
        assert_eq!(dedup_readonly_tool_calls_count(&calls), 2);
    }

    #[test]
    fn identical_calls_deduped() {
        let calls = vec![
            tc("1", "read_file", r#"{"path":"a.txt"}"#),
            tc("2", "read_file", r#"{"path":"a.txt"}"#),
            tc("3", "read_file", r#"{"path":"a.txt"}"#),
        ];
        assert_eq!(dedup_readonly_tool_calls_count(&calls), 1);
    }

    #[test]
    fn same_name_different_args_not_deduped() {
        let calls = vec![
            tc("1", "read_file", r#"{"path":"a.txt"}"#),
            tc("2", "read_file", r#"{"path":"b.txt"}"#),
        ];
        assert_eq!(dedup_readonly_tool_calls_count(&calls), 2);
    }

    #[test]
    fn mixed_duplicates() {
        let calls = vec![
            tc("1", "read_file", r#"{"path":"a.txt"}"#),
            tc("2", "list_dir", r#"{"path":"."}"#),
            tc("3", "read_file", r#"{"path":"a.txt"}"#),
            tc("4", "grep", r#"{"pattern":"foo"}"#),
            tc("5", "list_dir", r#"{"path":"."}"#),
        ];
        assert_eq!(dedup_readonly_tool_calls_count(&calls), 3);
    }

    #[test]
    fn empty_batch() {
        assert_eq!(dedup_readonly_tool_calls_count(&[]), 0);
    }
}

mod per_reflect_tests {
    use std::path::Path;
    use std::sync::Arc;

    use crate::agent::agent_turn::{
        RunLoopAttach, RunLoopCore, RunLoopCtx, RunLoopIo, RunLoopObs, RunLoopParams,
        RunLoopTurnState,
    };
    use crate::agent::per_coord::{FinalPlanRequirementMode, PerCoordinator, PerCoordinatorInit};
    use crate::llm::OPENAI_COMPAT_BACKEND;
    use crate::types::{FunctionCall, LlmSeedOverride, Message, MessageContent, ToolCall};

    use super::super::{ReflectOnAssistantOutcome, per_reflect_after_assistant};

    #[tokio::test]
    async fn proceed_to_tools_when_tool_calls_present_but_finish_reason_stop() {
        let cfg = Arc::new(crate::config::load_config(None).expect("embed default"));
        let client = reqwest::Client::new();
        let mut messages = vec![Message::user_only("x")];
        let mut c = PerCoordinator::new(PerCoordinatorInit {
            reflection_default_max_rounds: 5,
            final_plan_policy: FinalPlanRequirementMode::Never,
            plan_rewrite_max_attempts: 3,
            final_plan_require_strict_workflow_node_coverage: false,
            final_plan_semantic_check_enabled: false,
            final_plan_semantic_check_max_non_readonly_tools: 0,
        });
        let msg = Message {
            role: "assistant".to_string(),
            content: Some(MessageContent::Text("ok".to_string())),
            reasoning_content: None,
            reasoning_details: None,
            tool_calls: Some(vec![ToolCall {
                id: "1".into(),
                typ: "function".into(),
                function: FunctionCall {
                    name: "create_file".into(),
                    arguments: "{}".into(),
                },
            }]),
            name: None,
            tool_call_id: None,
        };
        let mut p = RunLoopParams {
            ctx: RunLoopCtx {
                core: RunLoopCore {
                    llm_backend: &OPENAI_COMPAT_BACKEND,
                    client: &client,
                    api_key: "",
                    cfg: &cfg,
                    tools_defs: &[],
                    effective_working_dir: Path::new("."),
                    workspace_is_set: false,
                },
                io: RunLoopIo {
                    no_stream: true,
                    cancel: None,
                    control: crate::agent::agent_turn::TurnControlSink {
                        out: None,
                        sse_encoder: crate::sse::default_encoder(),
                        sse_control_mirror: None,
                    },
                },
                attach: RunLoopAttach {
                    web_tool_ctx: None,
                    per_flight: None,
                    long_term_memory: None,
                    long_term_memory_scope_id: None,
                    mcp_turn: None,
                    read_file_turn_cache: None,
                    workspace_changelist: None,
                    turn_allowed_tool_names: None,
                    session_mode: crate::types::SessionMode::Act,
                },
                obs: RunLoopObs {
                    request_chrome_trace: None,
                    tracing_chat_turn: None,
                    request_audit: None,
                    process_handles: crate::process_handles::TurnProcessHandles::default_arc(),
                    trace_sink: None,
                    tool_job_registry: None,
                },
            },
            turn: RunLoopTurnState {
                messages_buf: &mut messages,
                messages_revision: 0,
                sub_phase: crate::agent::agent_turn::AgentTurnSubPhase::Planner,
                turn_planner_hints: crate::agent::agent_turn::TurnPlannerHints::default(),
                temperature_override: None,
                model_override: None,
                use_executor_model: false,
                executor_model_override: None,
                executor_api_base: None,
                executor_api_key: None,
                seed_override: LlmSeedOverride::FromConfig,
                turn_budget: crate::agent::turn_budget::TurnBudgetCounter::new_shared(),
                context_timeline: Default::default(),
                model_context_artifacts: Vec::new(),
                provider_usage: std::sync::Arc::new(std::sync::Mutex::new(None)),
            },
        };
        let out = per_reflect_after_assistant(&mut p, &mut c, "stop", &msg).await;
        assert!(matches!(
            out,
            ReflectOnAssistantOutcome::ProceedToExecuteTools
        ));
    }
}

mod workflow_binding_context_tests {
    use crate::agent::plan_artifact::parse_agent_reply_plan_v1_with_validate_only_binding_ids;

    fn workflow_bound_two_step_plan_json() -> &'static str {
        r#"{"type":"agent_reply_plan","version":1,"steps":[{"id":"s1","description":"step1","workflow_node_id":"node.a"},{"id":"s2","description":"step2","workflow_node_id":"node.b"}]}"#
    }

    #[test]
    fn rejects_workflow_bound_multi_step_without_validate_only_binding_ids() {
        assert!(
            parse_agent_reply_plan_v1_with_validate_only_binding_ids(
                workflow_bound_two_step_plan_json(),
                None
            )
            .is_err()
        );
    }

    #[test]
    fn allows_workflow_bound_multi_step_with_validate_only_binding_ids() {
        let ids = vec!["node.a".to_string(), "node.b".to_string()];
        assert!(
            parse_agent_reply_plan_v1_with_validate_only_binding_ids(
                workflow_bound_two_step_plan_json(),
                Some(ids.as_slice())
            )
            .is_ok()
        );
    }
}

mod multi_turn_orchestration_fixture_tests {
    use crate::agent::agent_turn::turn_completion::tool_calls_are_redundant_after_completion;
    use crate::types::{FunctionCall, ToolCall};

    #[test]
    fn redundant_probe_tools_detected_for_completed_outer_loop() {
        let tool_calls = vec![ToolCall {
            id: "tc1".into(),
            typ: "function".into(),
            function: FunctionCall {
                name: "list_tree".into(),
                arguments: "{}".into(),
            },
        }];
        assert!(tool_calls_are_redundant_after_completion(&tool_calls));
    }
}