M6: session-title generation via small_model
Generates a session title from a configured small_model after the first turn, replacing the default placeholder title in harness-app.
This commit is contained in:
@@ -16,7 +16,10 @@ use harness_core::config::{self, Config, ConfigError};
|
||||
use harness_core::engine::jobs::{JobBoard, JobState, LaunchSpec};
|
||||
use harness_core::engine::{run_session, Compactor, RunConfig, StepContext};
|
||||
use harness_core::event::{AppEvent, EventBus, RunOutcome};
|
||||
use harness_core::llm::{Initiator, LlmEvent, LlmRequest, Provider, ProviderError, WireMessage};
|
||||
use harness_core::llm::{
|
||||
Initiator, LlmEvent, LlmRequest, Provider, ProviderError, Role as WireRole, WireContent,
|
||||
WireMessage,
|
||||
};
|
||||
use harness_core::lsp::DiagnosticsSource;
|
||||
use harness_core::permission::{PermissionReply, PermissionService, Rule, Ruleset};
|
||||
use harness_core::store::{Store, StoreError};
|
||||
@@ -168,6 +171,9 @@ struct EngineInner {
|
||||
/// Auto-compaction backend (small model). `None` when no `small_model` is configured or
|
||||
/// its provider isn't registered — compaction is then disabled.
|
||||
compactor: Option<Arc<dyn Compactor>>,
|
||||
/// Resolved `small_model` (provider + model id) for session-title generation. `None`
|
||||
/// disables titling (the session keeps its empty title / prompt fallback).
|
||||
small_model: Option<(Arc<dyn Provider>, String)>,
|
||||
cwd: PathBuf,
|
||||
data_dir: PathBuf,
|
||||
runs: Mutex<HashMap<SessionId, RunHandle>>,
|
||||
@@ -259,18 +265,21 @@ impl EngineHandle {
|
||||
// `init` warms the cache in the background for the next launch.
|
||||
let catalog = ModelCatalog::load_cached_or_baked(&ModelCatalog::default_cache_path());
|
||||
|
||||
// Auto-compaction backend: the configured `small_model` (provider/model) if its provider
|
||||
// is registered. `None` disables compaction (the engine then runs with full history).
|
||||
let compactor: Option<Arc<dyn Compactor>> = config
|
||||
// The configured `small_model` (provider/model), resolved to a provider if registered.
|
||||
// Powers both auto-compaction and session-title generation; `None` disables both.
|
||||
let small_model: Option<(Arc<dyn Provider>, String)> = config
|
||||
.small_model
|
||||
.as_deref()
|
||||
.and_then(|m| m.split_once('/'))
|
||||
.and_then(|(provider_id, model_id)| {
|
||||
providers
|
||||
.get(provider_id)
|
||||
.map(|provider| SmallModelCompactor::new(provider, model_id.to_string()))
|
||||
})
|
||||
.map(|c| Arc::new(c) as Arc<dyn Compactor>);
|
||||
.map(|provider| (provider, model_id.to_string()))
|
||||
});
|
||||
let compactor: Option<Arc<dyn Compactor>> =
|
||||
small_model.clone().map(|(provider, model_id)| {
|
||||
Arc::new(SmallModelCompactor::new(provider, model_id)) as Arc<dyn Compactor>
|
||||
});
|
||||
|
||||
// LSP pool: built-ins present on PATH, plus any config-declared servers.
|
||||
let lsp_servers: Vec<harness_lsp::ServerConfig> = config
|
||||
@@ -300,6 +309,7 @@ impl EngineHandle {
|
||||
skills_prompt,
|
||||
commands,
|
||||
compactor,
|
||||
small_model,
|
||||
cwd,
|
||||
data_dir,
|
||||
runs: Mutex::new(HashMap::new()),
|
||||
@@ -397,6 +407,9 @@ impl EngineHandle {
|
||||
message: user_message.clone(),
|
||||
});
|
||||
|
||||
// Fire-and-forget session-title generation from the first user turn's text.
|
||||
self.inner.spawn_title_generation(&session_id, &text);
|
||||
|
||||
let user_part = Part {
|
||||
id: PartId::new(),
|
||||
message_id: user_message.id.clone(),
|
||||
@@ -582,6 +595,59 @@ impl EngineInner {
|
||||
.expect("EngineInner self-reference set in new()")
|
||||
}
|
||||
|
||||
/// Fires a background task that generates a short session title from the first user turn.
|
||||
/// No-op when no `small_model` is available; the title is only generated once (the task
|
||||
/// re-checks that the session is still untitled before writing).
|
||||
fn spawn_title_generation(&self, session_id: &SessionId, user_text: &str) {
|
||||
let Some((provider, model_id)) = self.small_model.clone() else {
|
||||
return;
|
||||
};
|
||||
let inner = self.arc();
|
||||
let session_id = session_id.clone();
|
||||
let user_text = user_text.to_string();
|
||||
tokio::spawn(async move {
|
||||
inner
|
||||
.generate_title(provider, model_id, session_id, user_text)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
/// Generates and stores a session title for a still-untitled root session.
|
||||
async fn generate_title(
|
||||
&self,
|
||||
provider: Arc<dyn Provider>,
|
||||
model_id: String,
|
||||
session_id: SessionId,
|
||||
user_text: String,
|
||||
) {
|
||||
// Only title a root session that hasn't been titled yet.
|
||||
match self.store.session(session_id.clone()).await {
|
||||
Ok(Some(s)) if s.depth == 0 && s.title.is_empty() => {}
|
||||
_ => return,
|
||||
}
|
||||
let raw = match oneshot_text(&provider, &model_id, TITLE_SYSTEM_PROMPT, &user_text).await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "session-title generation failed");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let title = sanitize_title(&raw);
|
||||
if title.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(Some(mut session)) = self.store.session(session_id.clone()).await {
|
||||
if !session.title.is_empty() {
|
||||
return; // titled concurrently; don't clobber
|
||||
}
|
||||
session.title = title;
|
||||
session.updated_at = now_ms();
|
||||
if self.store.upsert_session(session.clone()).await.is_ok() {
|
||||
self.bus.publish(AppEvent::SessionUpdated { session });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an agent definition by name, falling back to a generic assistant so an
|
||||
/// unknown/misconfigured agent still runs rather than failing the session.
|
||||
fn agent_def(&self, name: &str) -> AgentDef {
|
||||
@@ -780,6 +846,63 @@ impl ContextReporter for BoardReporter {
|
||||
}
|
||||
}
|
||||
|
||||
/// System prompt for session-title generation.
|
||||
const TITLE_SYSTEM_PROMPT: &str = "Generate a concise title (3 to 6 words) for a coding \
|
||||
session, summarizing what the user is asking for. No quotes, no trailing punctuation, no \
|
||||
preamble. Output only the title.";
|
||||
|
||||
/// Cap on a generated title's length, in characters.
|
||||
const MAX_TITLE_LEN: usize = 60;
|
||||
|
||||
/// Cleans a model-generated title: first non-empty line, stripped of surrounding quotes, and
|
||||
/// truncated to [`MAX_TITLE_LEN`] characters (on a char boundary).
|
||||
fn sanitize_title(raw: &str) -> String {
|
||||
let line = raw
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty())
|
||||
.unwrap_or("");
|
||||
let line = line
|
||||
.trim_matches(|c| c == '"' || c == '\'' || c == '`')
|
||||
.trim();
|
||||
line.chars().take(MAX_TITLE_LEN).collect()
|
||||
}
|
||||
|
||||
/// One-shot text completion: sends `user_text` under `system` to the model and concatenates the
|
||||
/// streamed text. Used for session titles (compaction uses the message-array form directly).
|
||||
async fn oneshot_text(
|
||||
provider: &Arc<dyn Provider>,
|
||||
model_id: &str,
|
||||
system: &str,
|
||||
user_text: &str,
|
||||
) -> Result<String, ProviderError> {
|
||||
use futures::StreamExt;
|
||||
|
||||
let req = LlmRequest {
|
||||
model: model_id.to_string(),
|
||||
system: vec![system.to_string()],
|
||||
messages: vec![WireMessage {
|
||||
role: WireRole::User,
|
||||
content: vec![WireContent::Text {
|
||||
text: user_text.to_string(),
|
||||
}],
|
||||
}],
|
||||
tools: Vec::new(),
|
||||
temperature: None,
|
||||
max_tokens: None,
|
||||
reasoning: None,
|
||||
initiator: Initiator::Agent,
|
||||
};
|
||||
let mut stream = provider.stream(req, CancellationToken::new()).await?;
|
||||
let mut out = String::new();
|
||||
while let Some(event) = stream.next().await {
|
||||
if let LlmEvent::TextDelta { text, .. } = event? {
|
||||
out.push_str(&text);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Auto-compaction backend: summarizes a conversation with the configured `small_model`.
|
||||
struct SmallModelCompactor {
|
||||
provider: Arc<dyn Provider>,
|
||||
@@ -1515,6 +1638,54 @@ mod tests {
|
||||
assert_eq!(stored.agent, "orchestrator");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn first_prompt_generates_a_session_title() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut providers = ProviderRegistry::new();
|
||||
providers.register(Arc::new(MockProvider));
|
||||
let config = Config {
|
||||
small_model: Some("mock/mock-model".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let engine = EngineHandle::build(
|
||||
dir.path().to_path_buf(),
|
||||
Store::open_in_memory().unwrap(),
|
||||
config,
|
||||
providers,
|
||||
Vec::new(),
|
||||
)
|
||||
.unwrap();
|
||||
spawn_auto_approve_task(&engine);
|
||||
let session = engine
|
||||
.new_session("orchestrator", "mock/mock-model")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut rx = engine.bus().subscribe();
|
||||
engine
|
||||
.prompt(
|
||||
session.clone(),
|
||||
"help me refactor the auth module".into(),
|
||||
"mock/mock-model",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The title-generation task (small model = the mock) publishes a SessionUpdated whose
|
||||
// title is the mock's canned text.
|
||||
let title = tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
loop {
|
||||
if let Ok(AppEvent::SessionUpdated { session: s }) = rx.recv().await {
|
||||
if s.id == session && !s.title.is_empty() {
|
||||
return s.title;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("a titled SessionUpdated within the timeout");
|
||||
assert_eq!(title, "explored the code");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_prompt_errors_on_unregistered_provider() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user