294 lines
11 KiB
Rust
294 lines
11 KiB
Rust
use std::path::PathBuf;
|
|
|
|
use galaxy_util::host_id::HostId;
|
|
use galaxy_util::local_or_remote_path::LocalOrRemotePath;
|
|
use galaxy_util::remote_path::RemotePath;
|
|
use galaxy_util::standardized_path::StandardizedPath;
|
|
use thiserror::Error;
|
|
use warp_multi_agent_api as api;
|
|
|
|
use crate::agent::action_result::{AnyFileContent, FileContext};
|
|
use crate::skills::{ParsedSkill, SkillProvider, SkillReference, SkillScope};
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum SkillConversionError {
|
|
#[error("No descriptor provided")]
|
|
MissingDescriptor,
|
|
#[error("No skill_reference provided")]
|
|
MissingReference,
|
|
#[error("No content provided")]
|
|
MissingContent,
|
|
#[error("Invalid scope")]
|
|
ScopeInvalid,
|
|
#[error("Invalid provider")]
|
|
ProviderInvalid,
|
|
#[error("Invalid content")]
|
|
ContentInvalid,
|
|
#[error("Skill path origin is unavailable")]
|
|
PathOriginUnavailable,
|
|
#[error("Invalid remote skill path")]
|
|
RemotePathInvalid,
|
|
}
|
|
/// Identifies how a string skill path from an API payload should be interpreted.
|
|
///
|
|
/// Live agent responses can be decoded from the active session's location. Restored payloads do
|
|
/// not carry enough session identity to safely reconstruct path-based skill locations, so callers
|
|
/// must use [`SkillPathOrigin::Unavailable`] rather than silently assuming the local filesystem.
|
|
#[derive(Debug, Clone, Eq, PartialEq)]
|
|
pub enum SkillPathOrigin {
|
|
Local,
|
|
Remote {
|
|
host_id: HostId,
|
|
},
|
|
/// Path identity could not be restored, but the API payload already carries the skill
|
|
/// descriptor and content needed to render a historical transcript.
|
|
///
|
|
/// This intentionally uses a local path wrapper only as a display-compatible identity for
|
|
/// restored conversation UI. Live execution paths should use [`SkillPathOrigin::Local`] or
|
|
/// [`SkillPathOrigin::Remote`] so local/remote provenance is preserved.
|
|
RestoredDisplayOnly,
|
|
Unavailable,
|
|
}
|
|
|
|
impl SkillPathOrigin {
|
|
pub fn location_for_path(
|
|
&self,
|
|
path: impl Into<String>,
|
|
) -> Result<LocalOrRemotePath, SkillConversionError> {
|
|
let path = path.into();
|
|
match self {
|
|
SkillPathOrigin::Local | SkillPathOrigin::RestoredDisplayOnly => {
|
|
// Normalize the path to collapse duplicate separators (e.g. `//workspace/...`
|
|
// → `/workspace/...`) so skill cache lookups match the filesystem-derived keys.
|
|
// We operate on the raw string rather than using `PathBuf::components().collect()`
|
|
// because the latter re-serialises with platform-specific separators (backslashes
|
|
// on Windows) and treats leading `//` as a UNC prefix on Windows.
|
|
let normalized = collapse_slashes(&path);
|
|
Ok(LocalOrRemotePath::Local(PathBuf::from(normalized)))
|
|
}
|
|
SkillPathOrigin::Remote { host_id } => {
|
|
let path = StandardizedPath::try_new(&path)
|
|
.map_err(|_| SkillConversionError::RemotePathInvalid)?;
|
|
Ok(LocalOrRemotePath::Remote(RemotePath::new(
|
|
host_id.clone(),
|
|
path,
|
|
)))
|
|
}
|
|
SkillPathOrigin::Unavailable => Err(SkillConversionError::PathOriginUnavailable),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collapse consecutive `/` separators into a single one.
|
|
///
|
|
/// Skill paths are always forward-slash POSIX-style paths on all platforms, so we normalise
|
|
/// at the string level rather than using [`std::path::PathBuf::components`], which would
|
|
/// re-serialise with backslashes on Windows and misinterpret `//prefix` as a UNC path.
|
|
fn collapse_slashes(path: &str) -> String {
|
|
let mut result = String::with_capacity(path.len());
|
|
let mut prev_slash = false;
|
|
for ch in path.chars() {
|
|
if ch == '/' {
|
|
if !prev_slash {
|
|
result.push(ch);
|
|
}
|
|
prev_slash = true;
|
|
} else {
|
|
result.push(ch);
|
|
prev_slash = false;
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
fn skill_reference_for_path(
|
|
path: impl Into<String>,
|
|
path_origin: &SkillPathOrigin,
|
|
) -> Result<SkillReference, SkillConversionError> {
|
|
path_origin
|
|
.location_for_path(path)
|
|
.map(SkillReference::Path)
|
|
}
|
|
|
|
pub fn skill_reference_from_api_skill_ref(
|
|
skill_ref: api::SkillRef,
|
|
path_origin: &SkillPathOrigin,
|
|
) -> Option<SkillReference> {
|
|
match skill_ref.skill_reference {
|
|
Some(api::skill_ref::SkillReference::Path(path)) => {
|
|
skill_reference_for_path(path, path_origin).ok()
|
|
}
|
|
Some(api::skill_ref::SkillReference::BundledSkillId(id)) => {
|
|
Some(SkillReference::BundledSkillId(id))
|
|
}
|
|
None => None,
|
|
}
|
|
}
|
|
|
|
pub fn skill_reference_from_read_skill_ref(
|
|
skill_reference: api::message::tool_call::read_skill::SkillReference,
|
|
path_origin: &SkillPathOrigin,
|
|
) -> Result<SkillReference, SkillConversionError> {
|
|
match skill_reference {
|
|
api::message::tool_call::read_skill::SkillReference::SkillPath(path) => {
|
|
skill_reference_for_path(path, path_origin)
|
|
}
|
|
api::message::tool_call::read_skill::SkillReference::BundledSkillId(id) => {
|
|
Ok(SkillReference::BundledSkillId(id))
|
|
}
|
|
}
|
|
}
|
|
impl From<ParsedSkill> for api::Skill {
|
|
fn from(skill: ParsedSkill) -> Self {
|
|
api::Skill {
|
|
descriptor: Some(api::SkillDescriptor {
|
|
skill_reference: Some(api::skill_descriptor::SkillReference::Path(
|
|
skill.path.display_path(),
|
|
)),
|
|
name: skill.name,
|
|
description: skill.description,
|
|
scope: Some(skill.scope.into()),
|
|
provider: Some(skill.provider.into()),
|
|
}),
|
|
content: Some(api::FileContent {
|
|
file_path: skill.path.display_path(),
|
|
content: skill.content,
|
|
line_range: skill
|
|
.line_range
|
|
.map(|line_range| api::FileContentLineRange {
|
|
start: line_range.start as u32,
|
|
end: line_range.end as u32,
|
|
}),
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SkillScope> for api::skill_descriptor::Scope {
|
|
fn from(scope: SkillScope) -> Self {
|
|
let scope_type: api::skill_descriptor::scope::Type = match scope {
|
|
SkillScope::Home => api::skill_descriptor::scope::Type::Home(()),
|
|
SkillScope::Project => api::skill_descriptor::scope::Type::Project(()),
|
|
SkillScope::Bundled => api::skill_descriptor::scope::Type::Bundled(()),
|
|
};
|
|
|
|
api::skill_descriptor::Scope {
|
|
r#type: Some(scope_type),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl From<SkillProvider> for api::skill_descriptor::Provider {
|
|
fn from(scope: SkillProvider) -> Self {
|
|
let provider_type: api::skill_descriptor::provider::Type = match scope {
|
|
SkillProvider::Warp => api::skill_descriptor::provider::Type::Warp(()),
|
|
SkillProvider::Agents => api::skill_descriptor::provider::Type::Agents(()),
|
|
SkillProvider::Claude => api::skill_descriptor::provider::Type::Claude(()),
|
|
SkillProvider::Codex => api::skill_descriptor::provider::Type::Codex(()),
|
|
SkillProvider::Cursor => api::skill_descriptor::provider::Type::Cursor(()),
|
|
SkillProvider::Gemini => api::skill_descriptor::provider::Type::Gemini(()),
|
|
SkillProvider::Copilot => api::skill_descriptor::provider::Type::Copilot(()),
|
|
SkillProvider::Droid => api::skill_descriptor::provider::Type::Droid(()),
|
|
SkillProvider::Github => api::skill_descriptor::provider::Type::Github(()),
|
|
SkillProvider::OpenCode => api::skill_descriptor::provider::Type::OpenCode(()),
|
|
};
|
|
|
|
api::skill_descriptor::Provider {
|
|
r#type: Some(provider_type),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<api::Skill> for ParsedSkill {
|
|
type Error = SkillConversionError;
|
|
|
|
fn try_from(api_skill: api::Skill) -> Result<Self, Self::Error> {
|
|
Self::try_from_api_with_origin(api_skill, &SkillPathOrigin::Unavailable)
|
|
}
|
|
}
|
|
|
|
impl ParsedSkill {
|
|
pub fn try_from_api_with_origin(
|
|
api_skill: api::Skill,
|
|
path_origin: &SkillPathOrigin,
|
|
) -> Result<Self, SkillConversionError> {
|
|
let Some(descriptor) = api_skill.descriptor else {
|
|
return Err(SkillConversionError::MissingDescriptor);
|
|
};
|
|
let Some(file_content) = api_skill.content else {
|
|
return Err(SkillConversionError::MissingContent);
|
|
};
|
|
let Some(skill_reference) = descriptor.skill_reference else {
|
|
return Err(SkillConversionError::MissingReference);
|
|
};
|
|
// TODO(pei): Once we refactor ParsedSkill to use SkillDescriptor,
|
|
// we can pass forward the reference directly to ParsedSkill
|
|
let path = match skill_reference {
|
|
api::skill_descriptor::SkillReference::Path(path) => path,
|
|
_ => "".to_string(), // This is ok only because we don't use the path
|
|
};
|
|
|
|
let Some(Ok(scope)) = descriptor.scope.map(convert_scope) else {
|
|
return Err(SkillConversionError::ScopeInvalid);
|
|
};
|
|
|
|
let Some(Ok(provider)) = descriptor.provider.map(convert_provider) else {
|
|
return Err(SkillConversionError::ProviderInvalid);
|
|
};
|
|
|
|
let context: FileContext = file_content.into();
|
|
let AnyFileContent::StringContent(content) = context.content else {
|
|
return Err(SkillConversionError::ContentInvalid);
|
|
};
|
|
|
|
let line_range = context.line_range.as_ref();
|
|
|
|
Ok(ParsedSkill {
|
|
path: path_origin.location_for_path(path)?,
|
|
name: descriptor.name,
|
|
description: descriptor.description,
|
|
content,
|
|
line_range: line_range.cloned(),
|
|
scope,
|
|
provider,
|
|
})
|
|
}
|
|
}
|
|
|
|
fn convert_scope(scope: api::skill_descriptor::Scope) -> Result<SkillScope, SkillConversionError> {
|
|
let Some(scope_type) = scope.r#type else {
|
|
return Err(SkillConversionError::ScopeInvalid);
|
|
};
|
|
|
|
match scope_type {
|
|
api::skill_descriptor::scope::Type::Home(_) => Ok(SkillScope::Home),
|
|
api::skill_descriptor::scope::Type::Project(_) => Ok(SkillScope::Project),
|
|
api::skill_descriptor::scope::Type::Bundled(_) => Ok(SkillScope::Bundled),
|
|
}
|
|
}
|
|
|
|
fn convert_provider(
|
|
provider: api::skill_descriptor::Provider,
|
|
) -> Result<SkillProvider, SkillConversionError> {
|
|
let Some(provider_type) = provider.r#type else {
|
|
return Err(SkillConversionError::ProviderInvalid);
|
|
};
|
|
|
|
match provider_type {
|
|
api::skill_descriptor::provider::Type::Warp(_) => Ok(SkillProvider::Warp),
|
|
api::skill_descriptor::provider::Type::Agents(_) => Ok(SkillProvider::Agents),
|
|
api::skill_descriptor::provider::Type::Claude(_) => Ok(SkillProvider::Claude),
|
|
api::skill_descriptor::provider::Type::Codex(_) => Ok(SkillProvider::Codex),
|
|
api::skill_descriptor::provider::Type::Cursor(_) => Ok(SkillProvider::Cursor),
|
|
api::skill_descriptor::provider::Type::Gemini(_) => Ok(SkillProvider::Gemini),
|
|
api::skill_descriptor::provider::Type::Copilot(_) => Ok(SkillProvider::Copilot),
|
|
api::skill_descriptor::provider::Type::Droid(_) => Ok(SkillProvider::Droid),
|
|
api::skill_descriptor::provider::Type::Github(_) => Ok(SkillProvider::Github),
|
|
api::skill_descriptor::provider::Type::OpenCode(_) => Ok(SkillProvider::OpenCode),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "conversion_tests.rs"]
|
|
mod conversion_tests;
|