roder-core 0.1.6

Agentic software development tools and SDKs for Roder.
Documentation
use roder_api::ToolSpec;
use roder_api::events::{
    RoderEvent, ThreadId, ToolCallValidationFailureClass, ToolCallValidationRecorded,
    ToolCallValidationRepairStatus, TurnId,
};
use roder_api::transcript::{ToolResultRecord, tool_display_payload};
use serde_json::Value;
use time::OffsetDateTime;

use crate::runtime::Runtime;

#[derive(Debug, Clone)]
pub(crate) struct ToolValidationError {
    pub(crate) failure_class: ToolCallValidationFailureClass,
    pub(crate) repair_status: ToolCallValidationRepairStatus,
    pub(crate) message: String,
}

pub(crate) async fn validate_tool_call_arguments(
    raw_arguments: &str,
    spec: &ToolSpec,
    thread_id: &ThreadId,
    turn_id: &TurnId,
    tool_id: &str,
    runtime: &Runtime,
) -> Result<Value, ToolValidationError> {
    let mut arguments: Value = match serde_json::from_str(raw_arguments) {
        Ok(value) => value,
        Err(err) => {
            let error = ToolValidationError {
                failure_class: ToolCallValidationFailureClass::InvalidJson,
                repair_status: ToolCallValidationRepairStatus::NotNeeded,
                message: format!("tool arguments must be valid JSON: {err}"),
            };
            emit_validation_error(runtime, thread_id, turn_id, tool_id, spec, &error).await;
            return Err(error);
        }
    };

    if spec.parameters.get("type").and_then(Value::as_str) == Some("object")
        && let Value::String(inner) = &arguments
    {
        match serde_json::from_str::<Value>(inner) {
            Ok(value) if value.is_object() => {
                arguments = value;
                emit_tool_validation_recorded(
                    runtime,
                    thread_id,
                    turn_id,
                    tool_id,
                    &spec.name,
                    ToolCallValidationFailureClass::SchemaRepairApplied,
                    ToolCallValidationRepairStatus::Applied,
                    "repaired stringified JSON object tool arguments".to_string(),
                )
                .await;
            }
            _ => {
                let error = ToolValidationError {
                    failure_class: ToolCallValidationFailureClass::SchemaRepairRejected,
                    repair_status: ToolCallValidationRepairStatus::Rejected,
                    message: "tool arguments were a string, but did not contain a JSON object"
                        .to_string(),
                };
                emit_validation_error(runtime, thread_id, turn_id, tool_id, spec, &error).await;
                return Err(error);
            }
        }
    }

    if let Err(error) = validate_value_against_schema(&arguments, &spec.parameters) {
        emit_validation_error(runtime, thread_id, turn_id, tool_id, spec, &error).await;
        return Err(error);
    }

    Ok(arguments)
}

#[allow(clippy::too_many_arguments)]
pub(crate) async fn emit_tool_validation_recorded(
    runtime: &Runtime,
    thread_id: &ThreadId,
    turn_id: &TurnId,
    tool_id: &str,
    tool_name: &str,
    failure_class: ToolCallValidationFailureClass,
    repair_status: ToolCallValidationRepairStatus,
    message: String,
) {
    runtime
        .emit(RoderEvent::ToolCallValidationRecorded(
            ToolCallValidationRecorded {
                thread_id: thread_id.clone(),
                turn_id: turn_id.clone(),
                tool_id: tool_id.to_string(),
                tool_name: tool_name.to_string(),
                failure_class,
                repair_status,
                message,
                timestamp: OffsetDateTime::now_utc(),
            },
        ))
        .await;
}

pub(crate) fn validation_error_tool_result(
    tool_id: &str,
    tool_name: &str,
    parsed_args: &Value,
    error: ToolValidationError,
) -> ToolResultRecord {
    ToolResultRecord {
        id: tool_id.to_string(),
        name: Some(tool_name.to_string()),
        result: format!("invalid tool call arguments: {}", error.message),
        display_payload: tool_display_payload(Some(tool_name), Some(parsed_args), None),
        is_error: true,
    }
}

async fn emit_validation_error(
    runtime: &Runtime,
    thread_id: &ThreadId,
    turn_id: &TurnId,
    tool_id: &str,
    spec: &ToolSpec,
    error: &ToolValidationError,
) {
    emit_tool_validation_recorded(
        runtime,
        thread_id,
        turn_id,
        tool_id,
        &spec.name,
        error.failure_class.clone(),
        error.repair_status.clone(),
        error.message.clone(),
    )
    .await;
}

fn validate_value_against_schema(value: &Value, schema: &Value) -> Result<(), ToolValidationError> {
    let Some(schema_type) = schema.get("type").and_then(Value::as_str) else {
        return Ok(());
    };
    if !value_matches_type(value, schema_type) {
        return Err(ToolValidationError {
            failure_class: ToolCallValidationFailureClass::WrongType,
            repair_status: ToolCallValidationRepairStatus::NotNeeded,
            message: format!(
                "tool arguments expected {schema_type}, got {}",
                value_kind(value)
            ),
        });
    }

    if schema_type == "object" {
        validate_object_against_schema(value, schema)?;
    }
    if schema_type == "array"
        && let Some(item_schema) = schema.get("items")
        && let Some(items) = value.as_array()
    {
        for item in items {
            validate_value_against_schema(item, item_schema)?;
        }
    }
    Ok(())
}

fn validate_object_against_schema(
    value: &Value,
    schema: &Value,
) -> Result<(), ToolValidationError> {
    let Some(object) = value.as_object() else {
        return Err(ToolValidationError {
            failure_class: ToolCallValidationFailureClass::WrongType,
            repair_status: ToolCallValidationRepairStatus::NotNeeded,
            message: format!("tool arguments expected object, got {}", value_kind(value)),
        });
    };
    let properties = schema
        .get("properties")
        .and_then(Value::as_object)
        .cloned()
        .unwrap_or_default();

    if let Some(required) = schema.get("required").and_then(Value::as_array) {
        for name in required.iter().filter_map(Value::as_str) {
            let Some(property) = object.get(name) else {
                return Err(ToolValidationError {
                    failure_class: ToolCallValidationFailureClass::MissingRequired,
                    repair_status: ToolCallValidationRepairStatus::NotNeeded,
                    message: format!("missing required tool argument `{name}`"),
                });
            };
            if properties
                .get(name)
                .and_then(|schema| schema.get("type"))
                .and_then(Value::as_str)
                == Some("string")
                && property
                    .as_str()
                    .is_some_and(|value| value.trim().is_empty())
            {
                return Err(ToolValidationError {
                    failure_class: ToolCallValidationFailureClass::EmptyRequiredString,
                    repair_status: ToolCallValidationRepairStatus::NotNeeded,
                    message: format!("required string tool argument `{name}` must not be empty"),
                });
            }
        }
    }

    if schema.get("additionalProperties").and_then(Value::as_bool) == Some(false) {
        for name in object.keys() {
            if !properties.contains_key(name) {
                return Err(ToolValidationError {
                    failure_class: ToolCallValidationFailureClass::UnexpectedProperty,
                    repair_status: ToolCallValidationRepairStatus::NotNeeded,
                    message: format!("unexpected tool argument `{name}`"),
                });
            }
        }
    }

    for (name, property_schema) in properties {
        if let Some(property) = object.get(&name) {
            validate_value_against_schema(property, &property_schema)?;
        }
    }
    Ok(())
}

fn value_matches_type(value: &Value, schema_type: &str) -> bool {
    match schema_type {
        "object" => value.is_object(),
        "array" => value.is_array(),
        "string" => value.is_string(),
        "integer" => value.as_i64().is_some() || value.as_u64().is_some(),
        "number" => value.is_number(),
        "boolean" => value.is_boolean(),
        "null" => value.is_null(),
        _ => true,
    }
}

fn value_kind(value: &Value) -> &'static str {
    match value {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}