Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1bdc98c
feat(hooks): add skill recommendation handler for user queries
laststylebender14 Mar 16, 2026
c1d03ff
refactor(tests): remove unused test cases and mock implementations
laststylebender14 Mar 16, 2026
7cf0282
fix(skill_recommendation): improve error logging by adding user query…
laststylebender14 Mar 16, 2026
3880f60
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 16, 2026
376e7b5
fix(orchestrator): correct context initialization in run method
laststylebender14 Mar 16, 2026
ccfdfad
refactor(skill_recommendation): use Element builder for message content
laststylebender14 Mar 16, 2026
3b0cd6c
feat(skill_recommendation): update recommendation message format for …
laststylebender14 Mar 16, 2026
4749c6e
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 16, 2026
a3f0907
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Mar 16, 2026
4ac4c04
refactor(tests): simplify home path assignment in fixture setup
laststylebender14 Mar 16, 2026
584cac3
Merge branch 'main' into feat/recommend-skills-based-on-user-query
laststylebender14 Mar 16, 2026
9cb1a9f
Update crates/forge_app/src/hooks/skill_recommendation.rs
laststylebender14 Mar 16, 2026
3afca25
Merge branch 'main' into feat/recommend-skills-based-on-user-query
tusharmath Mar 17, 2026
6045cf6
Merge branch 'main' into feat/recommend-skills-based-on-user-query
tusharmath Mar 21, 2026
7a3fd5f
feat: integrate skill recommendation template and update message hand…
laststylebender14 Mar 21, 2026
6d57cac
[autofix.ci] apply automated fixes
autofix-ci[bot] Mar 21, 2026
9c0543b
feat(skills): adjust recommendations and template logic
tusharmath Mar 24, 2026
5085523
Merge branch 'main' into feat/recommend-skills-based-on-user-query
tusharmath Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions crates/forge_app/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -144,8 +147,14 @@ impl<S: Services> ForgeApp<S> {
// 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
Expand Down
2 changes: 2 additions & 0 deletions crates/forge_app/src/hooks/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
108 changes: 108 additions & 0 deletions crates/forge_app/src/hooks/skill_recommendation.rs
Original file line number Diff line number Diff line change
@@ -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 `<recommended_skills>` 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<S> {
services: Arc<S>,
}

impl<S> SkillRecommendationHandler<S> {
/// Creates a new skill recommendation handler.
pub fn new(services: Arc<S>) -> Self {
Self { services }
}
}

#[async_trait]
impl<S: WorkspaceService> EventHandle<EventData<StartPayload>> for SkillRecommendationHandler<S> {
async fn handle(
&self,
event: &EventData<StartPayload>,
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::<Vec<_>>(),
"Injected skill recommendations"
);

Ok(())
}
}
4 changes: 2 additions & 2 deletions crates/forge_app/src/orch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,6 @@ impl<S: AgentService> Orchestrator<S> {
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(),
Expand All @@ -193,6 +191,8 @@ impl<S: AgentService> Orchestrator<S> {
.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;

Expand Down
21 changes: 21 additions & 0 deletions crates/forge_app/src/services.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,20 @@ pub trait WorkspaceService: Send + Sync {

/// Initialize a workspace without syncing files
async fn init_workspace(&self, path: PathBuf) -> anyhow::Result<WorkspaceId>;

/// 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<Vec<forge_domain::SelectedSkill>>;
}

#[async_trait::async_trait]
Expand Down Expand Up @@ -1160,4 +1174,11 @@ impl<I: Services> WorkspaceService for I {
async fn init_workspace(&self, path: PathBuf) -> anyhow::Result<WorkspaceId> {
self.workspace_service().init_workspace(path).await
}

async fn recommend_skills(
&self,
use_case: String,
) -> anyhow::Result<Vec<forge_domain::SelectedSkill>> {
self.workspace_service().recommend_skills(use_case).await
}
}
16 changes: 16 additions & 0 deletions crates/forge_domain/src/repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<crate::SelectedSkill>>;
}

/// Repository for managing skills
Expand Down
77 changes: 77 additions & 0 deletions crates/forge_domain/src/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>, description: impl Into<String>) -> 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<String>, 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<SkillInfo>,
/// 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<SkillInfo>, user_prompt: impl Into<String>) -> Self {
Self { skills, user_prompt: user_prompt.into() }
}
}

#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
Expand Down
4 changes: 1 addition & 3 deletions crates/forge_main/src/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
1 change: 0 additions & 1 deletion crates/forge_repo/proto/forge.proto
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,6 @@ message Skill {
message SelectedSkill {
string name = 1;
float relevance = 2;
uint64 rank = 3;
}

message SelectSkillRequest {
Expand Down
36 changes: 34 additions & 2 deletions crates/forge_repo/src/context_engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -383,4 +383,36 @@ impl<I: GrpcInfra> WorkspaceIndexRepository for ForgeContextEngineRepository<I>

Ok(())
}

async fn select_skill(
&self,
request: SkillSelectionParams,
auth_token: &ApiKey,
) -> Result<Vec<SelectedSkill>> {
let skills: Vec<proto_generated::Skill> = 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)
}
}
8 changes: 8 additions & 0 deletions crates/forge_repo/src/forge_repo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,14 @@ impl<F: GrpcInfra + Send + Sync> 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<Vec<forge_domain::SelectedSkill>> {
self.codebase_repo.select_skill(request, auth_token).await
}
}

#[async_trait::async_trait]
Expand Down
Loading
Loading