diff --git a/crates/forge_app/src/app.rs b/crates/forge_app/src/app.rs index ac770bdfe9..ca49b1abd5 100644 --- a/crates/forge_app/src/app.rs +++ b/crates/forge_app/src/app.rs @@ -10,7 +10,10 @@ use crate::apply_tunable_parameters::ApplyTunableParameters; use crate::authenticator::Authenticator; use crate::changed_files::ChangedFiles; use crate::dto::ToolsOverview; -use crate::hooks::{CompactionHandler, DoomLoopDetector, TitleGenerationHandler, TracingHandler}; +use crate::hooks::{ + CompactionHandler, DoomLoopDetector, SkillRecommendationHandler, TitleGenerationHandler, + TracingHandler, +}; use crate::init_conversation_metrics::InitConversationMetrics; use crate::orch::Orchestrator; use crate::services::{ @@ -144,8 +147,14 @@ impl ForgeApp { // Create the orchestrator with all necessary dependencies let tracing_handler = TracingHandler::new(); let title_handler = TitleGenerationHandler::new(services.clone()); + let skill_recommendation_handler = SkillRecommendationHandler::new(services.clone()); let hook = Hook::default() - .on_start(tracing_handler.clone().and(title_handler.clone())) + .on_start( + tracing_handler + .clone() + .and(title_handler.clone()) + .and(skill_recommendation_handler), + ) .on_request(tracing_handler.clone().and(DoomLoopDetector::default())) .on_response( tracing_handler diff --git a/crates/forge_app/src/hooks/mod.rs b/crates/forge_app/src/hooks/mod.rs index fb5447a8e6..8791f22b43 100644 --- a/crates/forge_app/src/hooks/mod.rs +++ b/crates/forge_app/src/hooks/mod.rs @@ -1,9 +1,11 @@ mod compaction; mod doom_loop; +mod skill_recommendation; mod title_generation; mod tracing; pub use compaction::CompactionHandler; pub use doom_loop::DoomLoopDetector; +pub use skill_recommendation::SkillRecommendationHandler; pub use title_generation::TitleGenerationHandler; pub use tracing::TracingHandler; diff --git a/crates/forge_app/src/hooks/skill_recommendation.rs b/crates/forge_app/src/hooks/skill_recommendation.rs new file mode 100644 index 0000000000..48d7a63ca2 --- /dev/null +++ b/crates/forge_app/src/hooks/skill_recommendation.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use forge_domain::{ + ContextMessage, Conversation, EventData, EventHandle, Role, StartPayload, TextMessage, +}; +use forge_template::Element; +use tracing::warn; + +use crate::WorkspaceService; + +/// Hook handler that injects skill recommendations as a droppable user message +/// at the start of each conversation turn. +/// +/// When the `Start` lifecycle event fires the handler: +/// 1. Extracts the raw user query from the most recent user message in the +/// conversation context. +/// 2. Calls [`WorkspaceService::recommend_skills`] which sends the query and +/// all available skills to the remote ranking service and returns only the +/// relevant skills with their relevance scores. +/// 3. Injects a droppable `User` message listing the recommended skills wrapped +/// in `` XML so the LLM can decide which to invoke. +/// +/// The injected message is marked droppable so it is automatically removed +/// during context compaction. +#[derive(Clone)] +pub struct SkillRecommendationHandler { + services: Arc, +} + +impl SkillRecommendationHandler { + /// Creates a new skill recommendation handler. + pub fn new(services: Arc) -> Self { + Self { services } + } +} + +#[async_trait] +impl EventHandle> for SkillRecommendationHandler { + async fn handle( + &self, + event: &EventData, + conversation: &mut Conversation, + ) -> anyhow::Result<()> { + // Extract the user query from the most-recent user message. + // Prefer the raw_content (original event value before template rendering); + // fall back to the rendered content string when raw_content is absent. + let user_query = conversation + .context + .as_ref() + .and_then(|c| c.messages.iter().rev().find(|m| m.has_role(Role::User))) + .and_then(|entry| { + entry + .message + .as_value() + .and_then(|v| v.as_user_prompt()) + .map(|p| p.as_str().to_owned()) + .or_else(|| entry.message.content().map(str::to_owned)) + }); + + let Some(user_query) = user_query else { + return Ok(()); + }; + + // Call the remote ranking service to get relevant skills for this query. + let selected = match self.services.recommend_skills(user_query.clone()).await { + Ok(s) => s, + Err(e) => { + warn!( + agent_id = %event.agent.id, + error = ?e, + query = %user_query, + "Failed to recommend skills, skipping" + ); + return Ok(()); + } + }; + + if selected.is_empty() { + return Ok(()); + } + + let message = TextMessage::new( + Role::User, + Element::new("recommended_skills") + .append(selected.iter().map(Element::from)) + .render(), + ) + .model(event.agent.model.clone()) + .droppable(true); + + let ctx = conversation + .context + .take() + .unwrap_or_default() + .add_message(ContextMessage::Text(message)); + conversation.context = Some(ctx); + + tracing::debug!( + agent_id = %event.agent.id, + user_query = %user_query, + skills = ?selected.iter().map(|s| s.name.as_str()).collect::>(), + "Injected skill recommendations" + ); + + Ok(()) + } +} diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index b189e95146..073475d3ee 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -181,8 +181,6 @@ impl Orchestrator { pub async fn run(&mut self) -> anyhow::Result<()> { let model_id = self.get_model(); - let mut context = self.conversation.context.clone().unwrap_or_default(); - // Fire the Start lifecycle event let start_event = LifecycleEvent::Start(EventData::new( self.agent.clone(), @@ -193,6 +191,8 @@ impl Orchestrator { .handle(&start_event, &mut self.conversation) .await?; + let mut context = self.conversation.context.clone().unwrap_or_default(); + // Signals that the loop should suspend (task may or may not be completed) let mut should_yield = false; diff --git a/crates/forge_app/src/services.rs b/crates/forge_app/src/services.rs index 0e1525af5f..2635d78d54 100644 --- a/crates/forge_app/src/services.rs +++ b/crates/forge_app/src/services.rs @@ -340,6 +340,20 @@ pub trait WorkspaceService: Send + Sync { /// Initialize a workspace without syncing files async fn init_workspace(&self, path: PathBuf) -> anyhow::Result; + + /// Recommend relevant skills for a given use case. + /// + /// Sends the user's query and the list of available skills to the remote + /// ranking service, which returns the most relevant skills ranked by + /// relevance score. + /// + /// # Errors + /// Returns an error if authentication, skill loading, or the remote ranking + /// call fails. + async fn recommend_skills( + &self, + use_case: String, + ) -> anyhow::Result>; } #[async_trait::async_trait] @@ -1160,4 +1174,11 @@ impl WorkspaceService for I { async fn init_workspace(&self, path: PathBuf) -> anyhow::Result { self.workspace_service().init_workspace(path).await } + + async fn recommend_skills( + &self, + use_case: String, + ) -> anyhow::Result> { + self.workspace_service().recommend_skills(use_case).await + } } diff --git a/crates/forge_domain/src/repo.rs b/crates/forge_domain/src/repo.rs index e3602f71ce..a40f8d3bed 100644 --- a/crates/forge_domain/src/repo.rs +++ b/crates/forge_domain/src/repo.rs @@ -176,6 +176,22 @@ pub trait WorkspaceIndexRepository: Send + Sync { workspace_id: &WorkspaceId, auth_token: &crate::ApiKey, ) -> anyhow::Result<()>; + + /// Select relevant skills for a user prompt using the remote ranking + /// service. + /// + /// # Arguments + /// * `request` - The skill selection parameters including candidate skills + /// and user prompt + /// * `auth_token` - API key used to authenticate with the remote service + /// + /// # Errors + /// Returns an error if the gRPC call fails or the response is malformed. + async fn select_skill( + &self, + request: crate::SkillSelectionParams, + auth_token: &crate::ApiKey, + ) -> anyhow::Result>; } /// Repository for managing skills diff --git a/crates/forge_domain/src/skill.rs b/crates/forge_domain/src/skill.rs index d86c27f2d6..174fada114 100644 --- a/crates/forge_domain/src/skill.rs +++ b/crates/forge_domain/src/skill.rs @@ -47,6 +47,83 @@ impl Skill { } } +/// Simplified skill information used when requesting skill selection. +/// +/// Contains only the name and description fields required to send a skill +/// selection request to the remote ranking service. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkillInfo { + /// Name of the skill + pub name: String, + /// Description of the skill + pub description: String, +} + +impl SkillInfo { + /// Creates a new skill info entry. + /// + /// # Arguments + /// * `name` - The skill identifier + /// * `description` - A brief description of what the skill does + pub fn new(name: impl Into, description: impl Into) -> Self { + Self { name: name.into(), description: description.into() } + } +} + +/// A skill selected based on relevance to a user prompt. +/// +/// Holds the name and relevance score returned after ranking available skills +/// against a user query. Used to inject skill hints as droppable context +/// messages before an LLM turn. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Setters)] +#[setters(strip_option, into)] +pub struct SelectedSkill { + /// Name of the selected skill + pub name: String, + /// Relevance score (0.0–1.0) of the skill against the user prompt + pub relevance: f32, +} + +impl SelectedSkill { + /// Creates a new selected skill entry. + /// + /// # Arguments + /// * `name` - The skill identifier + /// * `relevance` - How relevant the skill is (0.0–1.0) + pub fn new(name: impl Into, relevance: f32) -> Self { + Self { name: name.into(), relevance } + } +} + +impl From<&SelectedSkill> for forge_template::Element { + fn from(skill: &SelectedSkill) -> Self { + forge_template::Element::new("skill").attr("name", &skill.name) + } +} + +/// Request parameters for skill selection. +/// +/// Bundles the list of available skills and the user's prompt into a single +/// request to send to the remote ranking service. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillSelectionParams { + /// List of available skills to select from + pub skills: Vec, + /// User's prompt to match skills against + pub user_prompt: String, +} + +impl SkillSelectionParams { + /// Creates new skill selection parameters. + /// + /// # Arguments + /// * `skills` - The candidate skills to rank + /// * `user_prompt` - The user query used to score relevance + pub fn new(skills: Vec, user_prompt: impl Into) -> Self { + Self { skills, user_prompt: user_prompt.into() } + } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; diff --git a/crates/forge_main/src/info.rs b/crates/forge_main/src/info.rs index 3cf0dc97c6..747b879375 100644 --- a/crates/forge_main/src/info.rs +++ b/crates/forge_main/src/info.rs @@ -777,9 +777,7 @@ mod tests { use fake::{Fake, Faker}; let mut fixture: Environment = Faker.fake(); fixture = fixture.os(os.to_string()); - if let Some(home_path) = home { - fixture = fixture.home(PathBuf::from(home_path)); - } + fixture.home = home.map(PathBuf::from); fixture } diff --git a/crates/forge_repo/proto/forge.proto b/crates/forge_repo/proto/forge.proto index 5ea339a85d..568673fa93 100644 --- a/crates/forge_repo/proto/forge.proto +++ b/crates/forge_repo/proto/forge.proto @@ -333,7 +333,6 @@ message Skill { message SelectedSkill { string name = 1; float relevance = 2; - uint64 rank = 3; } message SelectSkillRequest { diff --git a/crates/forge_repo/src/context_engine.rs b/crates/forge_repo/src/context_engine.rs index ab9b34abd1..3e072a7df9 100644 --- a/crates/forge_repo/src/context_engine.rs +++ b/crates/forge_repo/src/context_engine.rs @@ -5,8 +5,8 @@ use async_trait::async_trait; use chrono::Utc; use forge_app::GrpcInfra; use forge_domain::{ - ApiKey, FileUploadInfo, Node, UserId, WorkspaceAuth, WorkspaceId, WorkspaceIndexRepository, - WorkspaceInfo, + ApiKey, FileUploadInfo, Node, SelectedSkill, SkillSelectionParams, UserId, WorkspaceAuth, + WorkspaceId, WorkspaceIndexRepository, WorkspaceInfo, }; use crate::proto_generated::forge_service_client::ForgeServiceClient; @@ -383,4 +383,36 @@ impl WorkspaceIndexRepository for ForgeContextEngineRepository Ok(()) } + + async fn select_skill( + &self, + request: SkillSelectionParams, + auth_token: &ApiKey, + ) -> Result> { + let skills: Vec = request + .skills + .into_iter() + .map(|skill| proto_generated::Skill { + name: skill.name, + description: skill.description, + }) + .collect(); + + let grpc_request = + tonic::Request::new(SelectSkillRequest { skills, user_prompt: request.user_prompt }); + + let grpc_request = self.with_auth(grpc_request, auth_token)?; + + let channel = self.infra.channel(); + let mut client = ForgeServiceClient::new(channel); + let response = client.select_skill(grpc_request).await?.into_inner(); + + let selected_skills = response + .skills + .into_iter() + .map(|skill| SelectedSkill::new(skill.name, skill.relevance)) + .collect(); + + Ok(selected_skills) + } } diff --git a/crates/forge_repo/src/forge_repo.rs b/crates/forge_repo/src/forge_repo.rs index dde20149e0..e7f9e7b261 100644 --- a/crates/forge_repo/src/forge_repo.rs +++ b/crates/forge_repo/src/forge_repo.rs @@ -585,6 +585,14 @@ impl forge_domain::WorkspaceIndexRepository for Forg .delete_workspace(workspace_id, auth_token) .await } + + async fn select_skill( + &self, + request: forge_domain::SkillSelectionParams, + auth_token: &forge_domain::ApiKey, + ) -> anyhow::Result> { + self.codebase_repo.select_skill(request, auth_token).await + } } #[async_trait::async_trait] diff --git a/crates/forge_services/src/context_engine.rs b/crates/forge_services/src/context_engine.rs index a194350754..af5f0b9f82 100644 --- a/crates/forge_services/src/context_engine.rs +++ b/crates/forge_services/src/context_engine.rs @@ -9,8 +9,9 @@ use forge_app::{ WorkspaceService, WorkspaceStatus, compute_hash, }; use forge_domain::{ - AuthCredential, AuthDetails, FileHash, FileNode, ProviderId, ProviderRepository, SyncProgress, - UserId, WorkspaceId, WorkspaceIndexRepository, + AuthCredential, AuthDetails, FileHash, FileNode, ProviderId, ProviderRepository, SkillInfo, + SkillRepository, SkillSelectionParams, SyncProgress, UserId, WorkspaceId, + WorkspaceIndexRepository, }; use forge_stream::MpscStream; use futures::future::join_all; @@ -506,6 +507,7 @@ impl< + EnvironmentInfra + CommandInfra + WalkerInfra + + SkillRepository + 'static, D: FileDiscovery + 'static, > WorkspaceService for ForgeWorkspaceService @@ -710,4 +712,23 @@ impl< Err(forge_domain::Error::WorkspaceAlreadyInitialized(workspace_id).into()) } } + + async fn recommend_skills(&self, use_case: String) -> Result> { + let (token, _user_id) = self.get_workspace_credentials().await?; + + let skill_infos: Vec = self + .infra + .load_skills() + .await? + .iter() + .map(|s| SkillInfo::new(&s.name, &s.description)) + .collect(); + + let params = SkillSelectionParams::new(skill_infos, use_case); + + self.infra + .select_skill(params, &token) + .await + .context("Failed to select skills") + } } diff --git a/templates/forge-partial-skill-instructions.md b/templates/forge-partial-skill-instructions.md index 700b359f83..9ef81f065f 100644 --- a/templates/forge-partial-skill-instructions.md +++ b/templates/forge-partial-skill-instructions.md @@ -5,12 +5,10 @@ How skills work: 1. **Invocation**: Use the `skill` tool with just the skill name parameter - - Example: Call skill tool with `{"name": "mock-calculator"}` - No additional arguments needed 2. **Response**: The tool returns the skill's details wrapped in `` containing: - - `` - The complete SKILL.md file content with the skill's path - `` tags - List of additional resource files available in the skill directory - Includes usage guidelines, instructions, and any domain-specific knowledge @@ -28,18 +26,7 @@ Examples of skill invocation: Important: -- Only invoke skills listed in `` below +- Only invoke skills listed by the skill tool - Do not invoke a skill that is already active/loaded - Skills are not CLI commands - use the skill tool to load them - After loading a skill, follow its specific instructions to help the user - - -{{#each skills}} - -{{this.name}} - -{{this.description}} - - -{{/each}} -