unified-agent-api 0.3.5

Agent-agnostic facade and registry for wrapper backends
Documentation
use std::{
    collections::{BTreeMap, BTreeSet},
    ffi::OsString,
    path::{Component, Path, PathBuf},
    time::Duration,
};

use serde_json::Value;
#[cfg(windows)]
use std::path::Prefix;

use super::{BackendDefaults, BackendHarnessAdapter, NormalizedRequest};
use crate::backends::spawn_path::resolve_relative_path_from_base;
use crate::{AgentWrapperError, AgentWrapperRunRequest, EXT_AGENT_API_CONFIG_MODEL_V1};

const ADD_DIRS_KEY: &str = "dirs";
const ADD_DIRS_ROOT_INVALID: &str = "invalid agent_api.exec.add_dirs.v1";
const ADD_DIRS_CONTAINER_INVALID: &str = "invalid agent_api.exec.add_dirs.v1.dirs";
const ADD_DIRS_MAX_COUNT: usize = 16;
const ADD_DIRS_MAX_ENTRY_BYTES: usize = 1024;
const MODEL_ID_INVALID: &str = "invalid agent_api.config.model.v1";
const MODEL_ID_MAX_BYTES: usize = 128;

#[cfg(windows)]
type AddDirDedupeKey = String;
#[cfg(not(windows))]
type AddDirDedupeKey = PathBuf;

fn validate_extension_keys_fail_closed<A: BackendHarnessAdapter>(
    adapter: &A,
    request: &AgentWrapperRunRequest,
) -> Result<(), AgentWrapperError> {
    let supported: &[&str] = adapter.supported_extension_keys();
    for key in request.extensions.keys() {
        if !supported.contains(&key.as_str()) {
            return Err(AgentWrapperError::UnsupportedCapability {
                agent_kind: adapter.kind().as_str().to_string(),
                capability: key.clone(),
            });
        }
    }
    Ok(())
}

fn merge_env_backend_defaults_then_request(
    defaults: &BTreeMap<String, String>,
    request: &BTreeMap<String, String>,
) -> BTreeMap<String, String> {
    let mut merged = defaults.clone();
    merged.extend(request.clone());
    merged
}

fn derive_effective_timeout(
    request_timeout: Option<Duration>,
    default_timeout: Option<Duration>,
) -> Option<Duration> {
    request_timeout.or(default_timeout)
}

fn normalize_model_id_v1(raw: Option<&Value>) -> Result<Option<String>, AgentWrapperError> {
    let Some(raw) = raw else {
        return Ok(None);
    };

    let raw = raw.as_str().ok_or_else(invalid_model_id)?;
    let trimmed = raw.trim();
    if trimmed.is_empty() || trimmed.len() > MODEL_ID_MAX_BYTES {
        return Err(invalid_model_id());
    }

    Ok(Some(trimmed.to_string()))
}

pub(crate) fn accepted_model_override_v1(
    request: &AgentWrapperRunRequest,
) -> Result<bool, AgentWrapperError> {
    Ok(normalize_model_id_v1(request.extensions.get(EXT_AGENT_API_CONFIG_MODEL_V1))?.is_some())
}

pub(crate) fn normalize_add_dirs_v1(
    raw: Option<&Value>,
    effective_working_dir: Option<&Path>,
) -> Result<Vec<PathBuf>, AgentWrapperError> {
    let Some(raw) = raw else {
        return Ok(Vec::new());
    };

    let object = raw.as_object().ok_or_else(invalid_add_dirs_root)?;

    if object.len() != 1 || !object.contains_key(ADD_DIRS_KEY) {
        return Err(invalid_add_dirs_root());
    }

    let dirs = object
        .get(ADD_DIRS_KEY)
        .ok_or_else(invalid_add_dirs_root)?
        .as_array()
        .ok_or_else(invalid_add_dirs_container)?;

    if dirs.is_empty() || dirs.len() > ADD_DIRS_MAX_COUNT {
        return Err(invalid_add_dirs_container());
    }

    let mut normalized_dirs = Vec::with_capacity(dirs.len());
    let mut seen = BTreeSet::new();
    for (index, entry) in dirs.iter().enumerate() {
        let normalized = normalize_add_dir_entry(entry, index, effective_working_dir)?;
        if seen.insert(add_dir_dedupe_key(&normalized)) {
            normalized_dirs.push(normalized);
        }
    }

    Ok(normalized_dirs)
}

pub(crate) fn normalize_request<A: BackendHarnessAdapter>(
    adapter: &A,
    defaults: &BackendDefaults,
    request: AgentWrapperRunRequest,
) -> Result<NormalizedRequest<A::Policy>, AgentWrapperError> {
    if request.prompt.trim().is_empty() {
        return Err(AgentWrapperError::InvalidRequest {
            message: "prompt must not be empty".to_string(),
        });
    }

    validate_extension_keys_fail_closed(adapter, &request)?;
    let model_id = normalize_model_id_v1(request.extensions.get(EXT_AGENT_API_CONFIG_MODEL_V1))?;
    let policy = adapter.validate_and_extract_policy(&request)?;

    let env = merge_env_backend_defaults_then_request(&defaults.env, &request.env);
    let effective_timeout = derive_effective_timeout(request.timeout, defaults.default_timeout);

    let agent_kind = adapter.kind();
    let prompt = request.prompt;
    let working_dir = request.working_dir;

    Ok(NormalizedRequest {
        agent_kind,
        prompt,
        model_id,
        working_dir,
        effective_timeout,
        env,
        policy,
    })
}

#[allow(dead_code)]
fn parse_ext_bool(value: &Value, key: &str) -> Result<bool, AgentWrapperError> {
    value
        .as_bool()
        .ok_or_else(|| AgentWrapperError::InvalidRequest {
            message: format!("{key} must be a boolean"),
        })
}

#[allow(dead_code)]
fn parse_ext_string<'a>(value: &'a Value, key: &str) -> Result<&'a str, AgentWrapperError> {
    value
        .as_str()
        .ok_or_else(|| AgentWrapperError::InvalidRequest {
            message: format!("{key} must be a string"),
        })
}

#[allow(dead_code)]
fn parse_ext_string_enum<'a>(
    value: &'a Value,
    key: &str,
    allowed: &[&str],
) -> Result<&'a str, AgentWrapperError> {
    let raw = parse_ext_string(value, key)?;
    if allowed.contains(&raw) {
        return Ok(raw);
    }

    let allowed = allowed.join(" | ");
    Err(AgentWrapperError::InvalidRequest {
        message: format!("{key} must be one of: {allowed}"),
    })
}

fn normalize_add_dir_entry(
    value: &Value,
    index: usize,
    effective_working_dir: Option<&Path>,
) -> Result<PathBuf, AgentWrapperError> {
    let raw = value
        .as_str()
        .ok_or_else(|| invalid_add_dirs_entry(index))?;
    let trimmed = raw.trim();
    if trimmed.is_empty() || trimmed.len() > ADD_DIRS_MAX_ENTRY_BYTES {
        return Err(invalid_add_dirs_entry(index));
    }

    let candidate = Path::new(trimmed);
    let resolved = if candidate.is_absolute() {
        candidate.to_path_buf()
    } else {
        let effective_working_dir =
            effective_working_dir.ok_or_else(|| invalid_add_dirs_entry(index))?;
        #[cfg(windows)]
        reject_cross_drive_windows_add_dir(candidate, effective_working_dir, index)?;
        resolve_relative_path_from_base(effective_working_dir, candidate)
    };
    let normalized = lexical_normalize_path(&resolved);

    if !normalized.exists() || !normalized.is_dir() {
        return Err(invalid_add_dirs_entry(index));
    }

    Ok(normalized)
}

fn add_dir_dedupe_key(path: &Path) -> AddDirDedupeKey {
    #[cfg(windows)]
    {
        return path.as_os_str().to_string_lossy().to_lowercase();
    }

    #[cfg(not(windows))]
    {
        path.to_path_buf()
    }
}

fn invalid_model_id() -> AgentWrapperError {
    AgentWrapperError::InvalidRequest {
        message: MODEL_ID_INVALID.to_string(),
    }
}

#[cfg(windows)]
fn reject_cross_drive_windows_add_dir(
    candidate: &Path,
    effective_working_dir: &Path,
    index: usize,
) -> Result<(), AgentWrapperError> {
    let Some(candidate_drive) = windows_drive_relative_prefix(candidate) else {
        return Ok(());
    };
    let Some(effective_drive) = windows_disk_prefix(effective_working_dir) else {
        return Ok(());
    };

    if candidate_drive == effective_drive {
        return Ok(());
    }

    Err(invalid_add_dirs_entry(index))
}

#[cfg(windows)]
fn windows_drive_relative_prefix(path: &Path) -> Option<u8> {
    let mut components = path.components();
    let Component::Prefix(prefix) = components.next()? else {
        return None;
    };

    let drive = match prefix.kind() {
        Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => drive.to_ascii_lowercase(),
        _ => return None,
    };

    for component in components {
        if matches!(component, Component::RootDir) {
            return None;
        }
    }

    Some(drive)
}

#[cfg(windows)]
fn windows_disk_prefix(path: &Path) -> Option<u8> {
    path.components().find_map(|component| match component {
        Component::Prefix(prefix) => match prefix.kind() {
            Prefix::Disk(drive) | Prefix::VerbatimDisk(drive) => Some(drive.to_ascii_lowercase()),
            _ => None,
        },
        _ => None,
    })
}

fn lexical_normalize_path(path: &Path) -> PathBuf {
    let mut prefix: Option<OsString> = None;
    let mut has_root = false;
    let mut parts: Vec<OsString> = Vec::new();

    for component in path.components() {
        match component {
            Component::Prefix(value) => {
                prefix = Some(value.as_os_str().to_os_string());
            }
            Component::RootDir => {
                has_root = true;
            }
            Component::CurDir => {}
            Component::ParentDir => {
                if let Some(last) = parts.last() {
                    if last != ".." {
                        parts.pop();
                        continue;
                    }
                }

                if !has_root {
                    parts.push(component.as_os_str().to_os_string());
                }
            }
            Component::Normal(value) => parts.push(value.to_os_string()),
        }
    }

    let mut normalized = PathBuf::new();
    if let Some(prefix) = prefix {
        normalized.push(PathBuf::from(prefix));
    }
    if has_root {
        normalized.push(std::path::MAIN_SEPARATOR_STR);
    }
    for part in parts {
        normalized.push(part);
    }
    if normalized.as_os_str().is_empty() {
        normalized.push(".");
    }

    normalized
}

fn invalid_add_dirs_root() -> AgentWrapperError {
    AgentWrapperError::InvalidRequest {
        message: ADD_DIRS_ROOT_INVALID.to_string(),
    }
}

fn invalid_add_dirs_container() -> AgentWrapperError {
    AgentWrapperError::InvalidRequest {
        message: ADD_DIRS_CONTAINER_INVALID.to_string(),
    }
}

fn invalid_add_dirs_entry(index: usize) -> AgentWrapperError {
    AgentWrapperError::InvalidRequest {
        message: format!("{ADD_DIRS_CONTAINER_INVALID}[{index}]"),
    }
}

#[cfg(test)]
mod tests;