lash-remote-protocol 0.1.0-alpha.59

Versioned remote embedding protocol DTOs for Lash sessions, turns, activities, tools, and LLM calls.
Documentation
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct RemoteToolGrant {
    pub protocol_version: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub id: Option<String>,
    pub name: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub description: String,
    #[serde(default = "default_input_schema")]
    pub input_schema: serde_json::Value,
    #[serde(default)]
    pub output_schema: serde_json::Value,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub input_schema_projections: Vec<RemoteSchemaProjectionOverride>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub output_schema_projections: Vec<RemoteSchemaProjectionOverride>,
    #[serde(default, skip_serializing_if = "RemoteToolOutputContract::is_static")]
    pub output_contract: RemoteToolOutputContract,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub examples: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub availability: Option<RemoteToolAvailability>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub activation: Option<RemoteToolActivation>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub argument_projection: Option<RemoteToolArgumentProjectionPolicy>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub scheduling: Option<RemoteToolScheduling>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retry_policy: Option<RemoteToolRetryPolicy>,
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub bindings: BTreeMap<String, serde_json::Value>,
}

impl RemoteToolGrant {
    pub fn binding_call_path(&self, binding_key: &str) -> Result<String, RemoteProtocolError> {
        let binding = self.required_call_path_binding(binding_key)?;
        Ok(format!(
            "{}.{}",
            binding.module_path.join("."),
            binding.operation
        ))
    }

    pub fn validate(&self) -> Result<(), RemoteProtocolError> {
        ensure_protocol_version(self.protocol_version)?;
        if self.name.trim().is_empty() {
            return Err(RemoteProtocolError::InvalidToolGrant {
                tool_name: self.name.clone(),
                message: "tool grant name cannot be empty".to_string(),
            });
        }
        for key in self.bindings.keys() {
            if key.trim().is_empty() {
                return Err(RemoteProtocolError::InvalidToolGrant {
                    tool_name: self.name.clone(),
                    message: "tool grant binding keys cannot be empty".to_string(),
                });
            }
        }
        Ok(())
    }

    pub fn validate_all(grants: &[Self]) -> Result<(), RemoteProtocolError> {
        let mut seen = HashSet::new();
        for grant in grants {
            grant.validate()?;
            for call_path in grant.call_path_bindings()? {
                if !seen.insert(call_path.clone()) {
                    return Err(RemoteProtocolError::DuplicateRemoteCallPath { call_path });
                }
            }
        }
        Ok(())
    }

    pub fn call_path_bindings(&self) -> Result<Vec<String>, RemoteProtocolError> {
        let mut paths = Vec::new();
        for (key, value) in &self.bindings {
            if let Some(binding) = RemoteCallPathBinding::from_value(value) {
                validate_call_path_binding(&self.name, key, &binding)?;
                paths.push(format!(
                    "{}.{}",
                    binding.module_path.join("."),
                    binding.operation
                ));
            }
        }
        Ok(paths)
    }

    fn required_call_path_binding(
        &self,
        binding_key: &str,
    ) -> Result<RemoteCallPathBinding, RemoteProtocolError> {
        let Some(value) = self.bindings.get(binding_key) else {
            return Err(RemoteProtocolError::MissingToolBinding {
                tool_name: self.name.clone(),
                binding: binding_key.to_string(),
            });
        };
        let Some(binding) = RemoteCallPathBinding::from_value(value) else {
            return Err(RemoteProtocolError::InvalidToolGrant {
                tool_name: self.name.clone(),
                message: format!("tool binding `{binding_key}` does not expose a call path"),
            });
        };
        validate_call_path_binding(&self.name, binding_key, &binding)?;
        Ok(binding)
    }
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RemoteCallPathBinding {
    pub module_path: Vec<String>,
    pub operation: String,
}

impl RemoteCallPathBinding {
    fn from_value(value: &serde_json::Value) -> Option<Self> {
        let module_path = value
            .get("module_path")?
            .as_array()?
            .iter()
            .map(|part| part.as_str().map(ToOwned::to_owned))
            .collect::<Option<Vec<_>>>()?;
        let operation = value.get("operation")?.as_str()?.to_string();
        Some(Self {
            module_path,
            operation,
        })
    }
}

fn validate_call_path_binding(
    tool_name: &str,
    binding_key: &str,
    binding: &RemoteCallPathBinding,
) -> Result<(), RemoteProtocolError> {
    if binding.module_path.is_empty() {
        return Err(RemoteProtocolError::InvalidToolGrant {
            tool_name: tool_name.to_string(),
            message: format!("tool binding `{binding_key}` requires an explicit module path"),
        });
    }
    if binding.module_path.iter().any(|part| part.trim().is_empty()) {
        return Err(RemoteProtocolError::InvalidToolGrant {
            tool_name: tool_name.to_string(),
            message: format!(
                "tool binding `{binding_key}` module path cannot contain empty segments"
            ),
        });
    }
    if binding.operation.trim().is_empty() {
        return Err(RemoteProtocolError::InvalidToolGrant {
            tool_name: tool_name.to_string(),
            message: format!("tool binding `{binding_key}` requires an explicit operation"),
        });
    }
    Ok(())
}

fn default_input_schema() -> serde_json::Value {
    serde_json::json!({
        "type": "object",
        "properties": {},
        "additionalProperties": true
    })
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct RemoteSchemaProjectionOverride {
    pub profile: String,
    pub schema: serde_json::Value,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RemoteToolAvailability {
    Off,
    Searchable,
    Callable,
    #[default]
    Showcased,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RemoteToolActivation {
    #[default]
    Always,
    Internal,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RemoteToolScheduling {
    #[default]
    Parallel,
    Serial,
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RemoteToolOutputContract {
    #[default]
    Static,
    FromInputSchema {
        input_field: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        default_schema: Option<serde_json::Value>,
    },
}

impl RemoteToolOutputContract {
    fn is_static(&self) -> bool {
        matches!(self, Self::Static)
    }
}

#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum RemoteToolArgumentProjectionPolicy {
    #[default]
    MaterializeProjectedValues,
    PreserveProjectedRefsInField {
        field: String,
    },
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RemoteToolRetryPolicy {
    #[default]
    Never,
    Safe {
        max_attempts: u32,
        base_delay_ms: u64,
        max_delay_ms: u64,
    },
    Idempotent {
        max_attempts: u32,
        base_delay_ms: u64,
        max_delay_ms: u64,
    },
}