ag-harness 0.14.7

Agentty is an ADE (Agentic Development Environment) for structured, controllable AI-assisted software development.
Documentation
use crate::chat_completion;

/// Standard Muse Spark 1.2 model whose prompts and completions are not used
/// to train Meta models.
pub const MUSE_SPARK_1_2: &str = "muse-spark-1.2";

/// Discounted Muse Spark 1.2 model that permits Meta to use prompts and
/// completions to train future models.
pub const MUSE_SPARK_1_2_CONTRIBUTOR: &str = "muse-spark-1.2-contributor";

pub(crate) const PROVIDER_NAME: &str = "meta";
pub(crate) const POLICY: chat_completion::ChatCompletionProviderPolicy =
    chat_completion::ChatCompletionProviderPolicy {
        display_name: "Meta Model API",
        structured_output: chat_completion::StructuredOutputMode::JsonSchema,
        telemetry_name: PROVIDER_NAME,
        unsupported_schema_reason: "Muse structured output requires an explicit object root schema",
    };

/// Configuration for a Muse model served through Meta's Model API.
pub struct MuseConfig {
    /// API key sent as a bearer token.
    pub api_key: String,
    /// API base URL ending in the OpenAI-compatible version path.
    pub base_url: String,
    /// Muse model identifier sent with each request.
    pub model: String,
}

#[cfg(test)]
mod tests {
    use serde_json::{Value, json};
    use wiremock::matchers::{bearer_token, body_json, method, path};
    use wiremock::{Mock, MockServer, ResponseTemplate};

    use super::*;
    use crate::{model, tool};

    fn person_schema_value() -> Value {
        json!({
            "type": "object",
            "properties": {
                "name": { "type": "string" }
            },
            "required": ["name"],
            "additionalProperties": false
        })
    }

    fn person_schema() -> crate::OutputSchema {
        crate::OutputSchema::new(person_schema_value()).expect("schema should be valid")
    }

    fn request(prompt: &str) -> model::ModelRequest {
        model::ModelRequest::new(prompt, person_schema())
    }

    fn read_request(prompt: &str) -> model::ModelRequest {
        request(prompt).with_tool(tool::ToolDefinition::read())
    }

    fn read_tool_wire() -> Value {
        let definition = tool::ToolDefinition::read();

        json!({
            "type": "function",
            "function": {
                "description": definition.description(),
                "name": definition.name(),
                "parameters": definition.parameters()
            }
        })
    }

    fn muse(server: &MockServer) -> model::ModelClient {
        model::ModelClient::muse(MuseConfig {
            api_key: "test-key".to_string(),
            base_url: format!("{}/", server.uri()),
            model: "muse-spark-1.2".to_string(),
        })
        .expect("fixture configuration should be valid")
    }

    fn response_format() -> Value {
        json!({
            "type": "json_schema",
            "json_schema": {
                "name": "ag_harness_output",
                "schema": person_schema_value()
            }
        })
    }

    #[test]
    fn exposes_standard_and_contributor_model_identifiers() {
        // Arrange and Act
        let models = [MUSE_SPARK_1_2, MUSE_SPARK_1_2_CONTRIBUTOR];

        // Assert
        assert_eq!(models, ["muse-spark-1.2", "muse-spark-1.2-contributor"]);
    }

    #[test]
    fn metadata_exposes_provider_and_model() {
        // Arrange
        let model = model::ModelClient::muse(MuseConfig {
            api_key: "test-key".to_string(),
            base_url: "https://api.meta.ai/v1".to_string(),
            model: MUSE_SPARK_1_2_CONTRIBUTOR.to_string(),
        })
        .expect("fixture configuration should be valid");

        // Act
        let metadata = model.metadata();

        // Assert
        assert_eq!(metadata.provider(), "meta");
        assert_eq!(metadata.model(), "muse-spark-1.2-contributor");
    }

    #[test]
    fn rejects_empty_model_during_construction() {
        // Arrange
        let config = MuseConfig {
            api_key: "test-key".to_string(),
            base_url: "https://api.meta.ai/v1".to_string(),
            model: "  ".to_string(),
        };

        // Act
        let error = model::ModelClient::muse(config)
            .err()
            .expect("empty model configuration should be rejected");

        // Assert
        assert_eq!(error, model::ModelMetadataError::EmptyModel);
    }

    #[tokio::test]
    async fn completes_native_json_schema_request() {
        // Arrange
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/chat/completions"))
            .and(bearer_token("test-key"))
            .and(body_json(json!({
                "messages": [
                    {"content": "extract the name", "role": "user"}
                ],
                "model": "muse-spark-1.2",
                "response_format": response_format()
            })))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "choices": [{
                    "finish_reason": "stop",
                    "message": {"content": r#"{"name":"Ada"}"#}
                }]
            })))
            .expect(1)
            .mount(&server)
            .await;
        let model = muse(&server);

        // Act
        let response = model
            .complete(request("extract the name"))
            .await
            .expect("Muse request should succeed");

        // Assert
        assert_eq!(response.output(), Some(&json!({ "name": "Ada" })));
    }

    #[tokio::test]
    async fn advertises_and_decodes_read_tool_call() {
        // Arrange
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/chat/completions"))
            .and(bearer_token("test-key"))
            .and(body_json(json!({
                "messages": [
                    {"content": "inspect the manifest", "role": "user"}
                ],
                "model": "muse-spark-1.2",
                "response_format": response_format(),
                "tools": [read_tool_wire()]
            })))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "choices": [{
                    "finish_reason": "tool_calls",
                    "message": {
                        "content": null,
                        "tool_calls": [{
                            "id": "call_muse_read",
                            "type": "function",
                            "function": {
                                "name": "read",
                                "arguments": r#"{"path":"Cargo.toml","offset":1,"limit":12}"#
                            }
                        }]
                    }
                }]
            })))
            .expect(1)
            .mount(&server)
            .await;
        let model = muse(&server);

        // Act
        let response = model
            .complete(read_request("inspect the manifest"))
            .await
            .expect("Muse read request should decode");

        // Assert
        assert!(response.output().is_none());
        let call = response
            .call()
            .expect("response should contain a tool call");
        assert_eq!(call.id(), "call_muse_read");
        assert_eq!(call.name(), "read");
        assert_eq!(call.arguments().path(), "Cargo.toml");
        assert_eq!(call.arguments().offset(), Some(1));
        assert_eq!(call.arguments().limit(), Some(12));
    }

    #[tokio::test]
    async fn retains_local_schema_validation() {
        // Arrange
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/chat/completions"))
            .respond_with(ResponseTemplate::new(200).set_body_json(json!({
                "choices": [{
                    "finish_reason": "stop",
                    "message": {"content": r#"{"name":42}"#}
                }]
            })))
            .mount(&server)
            .await;
        let model = muse(&server);

        // Act
        let error = model
            .complete(request("extract the name"))
            .await
            .expect_err("schema violation should fail");

        // Assert
        assert!(matches!(
            error,
            model::ModelError::SchemaViolation { path, reason }
                if path == "/name" && reason.contains("string")
        ));
    }

    #[tokio::test]
    async fn rejects_schemas_without_explicit_object_root() {
        // Arrange
        let server = MockServer::start().await;
        let model = muse(&server);
        let schema =
            crate::OutputSchema::new(json!({ "type": "array" })).expect("schema should be valid");

        // Act
        let error = model
            .complete(model::ModelRequest::new("list names", schema))
            .await
            .expect_err("schema without an explicit object root should fail");

        // Assert
        assert!(matches!(
            error,
            model::ModelError::UnsupportedOutputSchema { reason }
                if reason == "Muse structured output requires an explicit object root schema"
        ));
        assert!(
            server
                .received_requests()
                .await
                .expect("request recording should be enabled")
                .is_empty()
        );
    }

    #[tokio::test]
    async fn reports_meta_http_failure_without_exposing_the_key() {
        // Arrange
        let server = MockServer::start().await;
        Mock::given(method("POST"))
            .and(path("/chat/completions"))
            .respond_with(ResponseTemplate::new(401).set_body_json(json!({
                "error": {"message": "invalid API key"}
            })))
            .mount(&server)
            .await;
        let model = muse(&server);

        // Act
        let error = model
            .complete(request("hello"))
            .await
            .expect_err("HTTP failure should fail");
        let message = error.to_string();

        // Assert
        assert!(message.contains("Meta Model API returned HTTP 401 Unauthorized"));
        assert!(message.contains("invalid API key"));
        assert!(!message.contains("test-key"));
    }
}