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:
2026-07-10 15:30:47 +02:00
parent 5fd1698d47
commit 08b3cd8fb7
+178 -7
View File
@@ -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();