223 lines
7.4 KiB
Rust
223 lines
7.4 KiB
Rust
use ::ai::project_context::model::{ProjectContextModel, ProjectRule};
|
|
use ::ai::skills::{
|
|
get_provider_for_path, parse_skill_content_at_location, ParsedSkill, SkillProvider, SkillScope,
|
|
};
|
|
use galaxy_core::features::FeatureFlag;
|
|
use galaxy_core::safe_warn;
|
|
use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent};
|
|
use remote_server::proto::{
|
|
remote_skill_proto, RemoteAgentContextSnapshot, RemoteContextFileProto, RemoteSkillProto,
|
|
};
|
|
use warp_util::host_id::HostId;
|
|
use warp_util::local_or_remote_path::LocalOrRemotePath;
|
|
use warp_util::remote_path::RemotePath;
|
|
use warp_util::standardized_path::StandardizedPath;
|
|
use warpui::{Entity, ModelContext, SingletonEntity};
|
|
|
|
use super::mcp::McpIntegration;
|
|
use super::skills::{BundledSkill, BundledSkillActivation, SkillManager};
|
|
|
|
/// Home skills parsed from a remote agent context snapshot.
|
|
struct HomeSkills {
|
|
home_dir: LocalOrRemotePath,
|
|
skills: Vec<ParsedSkill>,
|
|
}
|
|
|
|
/// Valid application state parsed from a remote agent context snapshot.
|
|
struct RemoteAgentContextState {
|
|
bundled_skills: Option<BundledSkill>,
|
|
home_skills: Option<HomeSkills>,
|
|
global_rules: Vec<ProjectRule>,
|
|
}
|
|
|
|
pub(crate) struct RemoteAgentContext;
|
|
|
|
impl RemoteAgentContext {
|
|
pub(crate) fn new(ctx: &mut ModelContext<Self>) -> Self {
|
|
let remote_server_manager = RemoteServerManager::handle(ctx);
|
|
ctx.subscribe_to_model(&remote_server_manager, |me, _, event, ctx| {
|
|
if let RemoteServerManagerEvent::RemoteAgentContextSnapshot { host_id, snapshot } =
|
|
event
|
|
{
|
|
me.reconcile_snapshot(host_id.clone(), snapshot.clone(), ctx);
|
|
return;
|
|
}
|
|
if let RemoteServerManagerEvent::HostDisconnected { host_id } = event {
|
|
me.remove_host_context(host_id, ctx);
|
|
}
|
|
});
|
|
Self
|
|
}
|
|
|
|
fn reconcile_snapshot(
|
|
&mut self,
|
|
host_id: HostId,
|
|
snapshot: RemoteAgentContextSnapshot,
|
|
ctx: &mut ModelContext<Self>,
|
|
) {
|
|
let RemoteAgentContextState {
|
|
bundled_skills,
|
|
home_skills,
|
|
global_rules,
|
|
} = parse_snapshot(&host_id, snapshot);
|
|
SkillManager::handle(ctx).update(ctx, |manager, _| {
|
|
manager.replace_remote_agent_context(
|
|
host_id.clone(),
|
|
bundled_skills,
|
|
home_skills.map(|home| (home.home_dir, home.skills)),
|
|
);
|
|
});
|
|
ProjectContextModel::handle(ctx).update(ctx, |model, _| {
|
|
model.set_remote_global_rules(host_id, global_rules);
|
|
});
|
|
}
|
|
|
|
fn remove_host_context(&mut self, host_id: &HostId, ctx: &mut ModelContext<Self>) {
|
|
SkillManager::handle(ctx).update(ctx, |manager, _| {
|
|
manager.remove_remote_agent_context(host_id);
|
|
});
|
|
ProjectContextModel::handle(ctx).update(ctx, |model, _| {
|
|
model.remove_remote_global_rules(host_id);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn parse_snapshot(
|
|
host_id: &HostId,
|
|
snapshot: RemoteAgentContextSnapshot,
|
|
) -> RemoteAgentContextState {
|
|
let bundled_skills = FeatureFlag::BundledSkills
|
|
.is_enabled()
|
|
.then(|| bundled_skill_from_protos(host_id, &snapshot.skills));
|
|
let Some(home_dir) = remote_path(host_id, &snapshot.home_dir) else {
|
|
safe_warn!(
|
|
safe: ("Ignoring remote home context with an invalid home directory"),
|
|
full: ("Ignoring remote home context with an invalid home directory for {host_id}")
|
|
);
|
|
return RemoteAgentContextState {
|
|
bundled_skills,
|
|
home_skills: None,
|
|
global_rules: Vec::new(),
|
|
};
|
|
};
|
|
let skills = snapshot
|
|
.skills
|
|
.iter()
|
|
.filter(|proto| matches!(proto.source, Some(remote_skill_proto::Source::Home(_))))
|
|
.filter_map(|proto| {
|
|
parse_remote_skill(
|
|
host_id,
|
|
proto,
|
|
SkillScope::Home,
|
|
Some(&home_dir),
|
|
get_provider_for_path,
|
|
)
|
|
})
|
|
.collect();
|
|
let global_rules = snapshot
|
|
.global_rules
|
|
.into_iter()
|
|
.filter_map(|file| project_rule_within_home(host_id, file, &home_dir))
|
|
.collect();
|
|
RemoteAgentContextState {
|
|
bundled_skills,
|
|
home_skills: Some(HomeSkills { home_dir, skills }),
|
|
global_rules,
|
|
}
|
|
}
|
|
|
|
fn parse_remote_skill(
|
|
host_id: &HostId,
|
|
proto: &RemoteSkillProto,
|
|
scope: SkillScope,
|
|
required_root: Option<&LocalOrRemotePath>,
|
|
provider_for_path: impl FnOnce(&LocalOrRemotePath) -> Option<SkillProvider>,
|
|
) -> Option<ParsedSkill> {
|
|
let Some(path) = remote_path(host_id, &proto.path) else {
|
|
safe_warn!(
|
|
safe: ("Skipping remote skill with an invalid path"),
|
|
full: ("Skipping remote skill with an invalid path: {}", proto.path)
|
|
);
|
|
return None;
|
|
};
|
|
if required_root.is_some_and(|root| !path.starts_with(root)) {
|
|
return None;
|
|
}
|
|
let provider = provider_for_path(&path)?;
|
|
match parse_skill_content_at_location(path, &proto.content, provider, scope) {
|
|
Ok(skill) => Some(skill),
|
|
Err(err) => {
|
|
safe_warn!(
|
|
safe: ("Skipping remote skill that failed to parse"),
|
|
full: ("Skipping remote skill at {} that failed to parse: {err:#}", proto.path)
|
|
);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn bundled_skill_from_protos(host_id: &HostId, skills: &[RemoteSkillProto]) -> BundledSkill {
|
|
let definitions = skills.iter().filter_map(|proto| {
|
|
let remote_skill_proto::Source::Bundled(metadata) = proto.source.as_ref()? else {
|
|
return None;
|
|
};
|
|
let skill = parse_remote_skill(
|
|
host_id,
|
|
proto,
|
|
SkillScope::Bundled,
|
|
None,
|
|
|_| Some(SkillProvider::Warp),
|
|
)?;
|
|
let activation = match metadata.requires_mcp.as_deref() {
|
|
None => BundledSkillActivation::Always,
|
|
Some(wire_id) => match mcp_integration_from_wire_id(wire_id) {
|
|
Some(integration) => BundledSkillActivation::RequiresMcp(integration),
|
|
None => {
|
|
safe_warn!(
|
|
safe: ("Skipping bundled skill with an unknown MCP integration"),
|
|
full: ("Skipping bundled skill {} with an unknown MCP integration: {wire_id}", metadata.id)
|
|
);
|
|
return None;
|
|
}
|
|
},
|
|
};
|
|
Some((metadata.id.clone(), skill, activation))
|
|
});
|
|
BundledSkill::from_definitions(definitions)
|
|
}
|
|
|
|
fn mcp_integration_from_wire_id(wire_id: &str) -> Option<McpIntegration> {
|
|
match wire_id {
|
|
"figma" => Some(McpIntegration::Figma),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn remote_path(host_id: &HostId, path: &str) -> Option<LocalOrRemotePath> {
|
|
StandardizedPath::try_new(path)
|
|
.ok()
|
|
.map(|path| LocalOrRemotePath::Remote(RemotePath::new(host_id.clone(), path)))
|
|
}
|
|
|
|
fn project_rule_within_home(
|
|
host_id: &HostId,
|
|
file: RemoteContextFileProto,
|
|
home_dir: &LocalOrRemotePath,
|
|
) -> Option<ProjectRule> {
|
|
let path = remote_path(host_id, &file.path)?;
|
|
path.starts_with(home_dir).then_some(ProjectRule {
|
|
path,
|
|
content: file.content,
|
|
})
|
|
}
|
|
|
|
impl Entity for RemoteAgentContext {
|
|
type Event = ();
|
|
}
|
|
|
|
impl SingletonEntity for RemoteAgentContext {}
|
|
|
|
#[cfg(test)]
|
|
#[path = "remote_agent_context_tests.rs"]
|
|
mod tests;
|