1use bevy_ecs::prelude::{Commands, Component, Entity, Query, Res, ResMut, Resource, With, Without};
17use leviath_providers::InferenceRequest;
18use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender};
19
20use crate::persistence::RunMetadata;
21use crate::pipeline::{InferenceStage, Providers};
22use crate::title_bridge::{TitleJob, TitleOutcome, run_title_job};
23
24#[derive(Resource, Clone)]
27pub struct TitleSettings(pub leviath_core::config::TitleConfig);
28
29#[derive(Component, Debug, Clone, Copy)]
32pub struct PendingTitle;
33
34#[derive(Component, Debug, Clone, Copy)]
37pub struct AwaitingTitle;
38
39#[derive(Resource)]
41pub struct TitleResults(pub UnboundedReceiver<TitleOutcome>);
42
43#[derive(Resource)]
45pub struct TitleSink(pub UnboundedSender<TitleOutcome>);
46
47const TITLE_MAX_TOKENS: usize = 64;
49const TITLE_TASK_BUDGET: usize = 2_000;
52const TITLE_MAX_LEN: usize = 80;
54
55const TITLE_SYSTEM_PROMPT: &str = "Reply with only a short title for the given task, \
56 at most 8 words. No quotes, no trailing punctuation, no explanation.";
57
58fn resolve_title_model(
66 settings: &leviath_core::config::TitleConfig,
67 run_model_label: Option<&str>,
68) -> Option<(String, String)> {
69 let run = run_model_label.and_then(|label| label.split_once('/'));
70 let provider = settings
71 .provider
72 .clone()
73 .or_else(|| run.map(|(p, _)| p.to_string()))?;
74 let model = settings.model.clone().or_else(|| match run {
75 Some((run_provider, run_model)) if run_provider == provider => Some(run_model.to_string()),
76 _ => None,
77 })?;
78 Some((provider, model))
79}
80
81fn title_request(task: &str, model: &str) -> InferenceRequest {
83 InferenceRequest {
84 system: vec![],
85 messages: vec![
86 leviath_providers::Message {
87 role: "system".to_string(),
88 content: TITLE_SYSTEM_PROMPT.to_string().into(),
89 cache_breakpoint: false,
90 },
91 leviath_providers::Message {
92 role: "user".to_string(),
93 content: leviath_core::truncate_at_boundary(task, TITLE_TASK_BUDGET)
94 .to_string()
95 .into(),
96 cache_breakpoint: false,
97 },
98 ],
99 model: model.to_string(),
100 max_tokens: TITLE_MAX_TOKENS,
101 temperature: 0.2,
102 tools: Vec::new(),
103 extra: serde_json::Value::Null,
104 request_timeout_secs: None,
105 }
106}
107
108fn sanitize_title(raw: &str) -> String {
112 let first = raw
113 .lines()
114 .map(str::trim)
115 .find(|l| !l.is_empty())
116 .unwrap_or("");
117 let unquoted = first.trim_matches(['"', '\'', '`']).trim();
118 leviath_core::truncate_at_boundary(unquoted, TITLE_MAX_LEN)
119 .trim_end()
120 .to_string()
121}
122
123#[allow(clippy::type_complexity)]
129pub fn dispatch_title(
130 agents: Query<(Entity, &RunMetadata), (With<PendingTitle>, Without<AwaitingTitle>)>,
131 settings: Option<Res<TitleSettings>>,
132 stage: Res<InferenceStage>,
133 providers: Res<Providers>,
134 sink: Res<TitleSink>,
135 mut commands: Commands,
136) {
137 crate::tick_scope::clear();
138 for (entity, meta) in agents.iter() {
139 crate::tick_scope::enter(entity);
140 let resolved = settings
141 .as_ref()
142 .filter(|s| s.0.enabled)
143 .and_then(|s| resolve_title_model(&s.0, meta.model.as_deref()));
144 let Some((provider_name, model)) = resolved else {
145 tracing::debug!(run_id = %meta.run_id, "no usable title provider/model; skipping");
146 commands.entity(entity).remove::<PendingTitle>();
147 continue;
148 };
149 let Some(provider) = providers.0.get(&provider_name) else {
150 tracing::debug!(
151 run_id = %meta.run_id,
152 provider = %provider_name,
153 "title provider not registered; skipping"
154 );
155 commands.entity(entity).remove::<PendingTitle>();
156 continue;
157 };
158 let Some(permit) = stage.pools.try_acquire(&model) else {
159 continue; };
161
162 stage.runtime.spawn(run_title_job(
163 TitleJob {
164 entity,
165 provider,
166 request: title_request(&meta.task, &model),
167 permit,
168 },
169 std::time::Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
170 sink.0.clone(),
171 stage.wake.clone(),
172 ));
173 commands
174 .entity(entity)
175 .remove::<PendingTitle>()
176 .insert(AwaitingTitle);
177 }
178}
179
180pub fn collect_title(
184 mut results: ResMut<TitleResults>,
185 mut agents: Query<&mut RunMetadata, With<AwaitingTitle>>,
186 mut commands: Commands,
187) {
188 crate::tick_scope::clear();
189 while let Ok(outcome) = results.0.try_recv() {
190 let Ok(mut meta) = agents.get_mut(outcome.entity) else {
191 continue; };
193 crate::tick_scope::enter(outcome.entity);
194 if let Ok(raw) = outcome.result {
195 let title = sanitize_title(&raw);
196 if !title.is_empty() {
197 meta.title = Some(title);
198 }
199 }
200 commands.entity(outcome.entity).remove::<AwaitingTitle>();
201 }
202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use bevy_ecs::schedule::Schedule;
208 use bevy_ecs::world::World;
209 use leviath_providers::{Provider, ProviderError};
210 use std::sync::Arc;
211 use tokio::runtime::Handle;
212 use tokio::sync::Notify;
213 use tokio::sync::mpsc;
214
215 struct Scripted(Result<&'static str, &'static str>);
217
218 #[async_trait::async_trait]
219 impl Provider for Scripted {
220 async fn infer(
221 &self,
222 _r: InferenceRequest,
223 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
224 match self.0 {
225 Ok(reply) => Ok(leviath_providers::InferenceResponse {
226 content: reply.to_string(),
227 tool_calls: vec![],
228 tokens_used: leviath_providers::TokenUsage {
229 prompt_tokens: 1,
230 completion_tokens: 1,
231 total_tokens: 2,
232 cached_tokens: 0,
233 cache_write_tokens: 0,
234 },
235 finish_reason: leviath_providers::FinishReason::Complete,
236 }),
237 Err(msg) => Err(ProviderError::Other(msg.to_string())),
238 }
239 }
240 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
241 1
242 }
243 fn max_context_tokens(&self, _m: &str) -> usize {
244 100_000
245 }
246 fn name(&self) -> &str {
247 "mock"
248 }
249 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
250 leviath_providers::ModelCapabilities::default()
251 }
252 }
253
254 fn metadata(model: Option<&str>) -> RunMetadata {
255 RunMetadata {
256 run_id: "run-t".to_string(),
257 agent_name: "titled".to_string(),
258 agent_path: "/a".to_string(),
259 task: "summarize the release notes".to_string(),
260 model: model.map(str::to_string),
261 workdir: "/w".to_string(),
262 num_stages: 1,
263 started_at: 0,
264 parent_run_id: None,
265 metadata: Default::default(),
266 callback_url: None,
267 callback_secret: None,
268 title: None,
269 unattended: false,
270 read_paths: None,
271 }
272 }
273
274 fn build_world(
277 reply: Result<&'static str, &'static str>,
278 pools: crate::inference_pool::InferencePools,
279 ) -> (World, mpsc::UnboundedReceiver<TitleOutcome>) {
280 let mut registry = crate::ProviderRegistry::new();
281 registry.register("mock".to_string(), Arc::new(Scripted(reply)));
282 let (title_tx, title_rx) = mpsc::unbounded_channel();
283 let (inf_tx, _inf_rx) = mpsc::unbounded_channel();
284 let (ttx, _trx) = mpsc::unbounded_channel();
285 let (ctx, _crx) = mpsc::unbounded_channel();
286 let (cstx, _csrx) = mpsc::unbounded_channel();
287 let mut world = World::new();
288 world.insert_resource(Providers(registry));
289 world.insert_resource(InferenceStage {
290 pools: Arc::new(pools),
291 outcomes: inf_tx,
292 transition_outcomes: ttx,
293 compaction_outcomes: ctx,
294 content_summary_outcomes: cstx,
295 wake: Arc::new(Notify::new()),
296 runtime: Handle::current(),
297 exact_token_counting: false,
298 });
299 world.insert_resource(TitleSink(title_tx));
300 (world, title_rx)
301 }
302
303 fn run_dispatch(world: &mut World) {
304 let mut schedule = Schedule::default();
305 schedule.add_systems(dispatch_title);
306 schedule.run(world);
307 }
308
309 fn run_collect(world: &mut World) {
310 let mut schedule = Schedule::default();
311 schedule.add_systems(collect_title);
312 schedule.run(world);
313 }
314
315 fn default_pools() -> crate::inference_pool::InferencePools {
316 crate::inference_pool::InferencePools::new(crate::inference_pool::InferencePoolConfig::new())
317 }
318
319 #[tokio::test]
322 async fn title_deadline_frees_the_slot_when_the_provider_hangs() {
323 struct Hang;
324 #[async_trait::async_trait]
325 impl Provider for Hang {
326 async fn infer(
327 &self,
328 _r: InferenceRequest,
329 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
330 std::future::pending().await
331 }
332 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
333 1
334 }
335 fn max_context_tokens(&self, _m: &str) -> usize {
336 100_000
337 }
338 fn name(&self) -> &str {
339 "hang"
340 }
341 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
342 leviath_providers::ModelCapabilities::default()
343 }
344 }
345
346 assert_eq!(Hang.count_tokens("t", "m").await, 1);
348 assert_eq!(Hang.max_context_tokens("m"), 100_000);
349 assert_eq!(Hang.name(), "hang");
350 let _ = Hang.capabilities("m");
351 let pools = crate::inference_pool::InferencePools::new(
352 crate::inference_pool::InferencePoolConfig::new(),
353 );
354 let (tx, mut rx) = mpsc::unbounded_channel();
355 run_title_job(
356 TitleJob {
357 entity: bevy_ecs::entity::Entity::PLACEHOLDER,
358 provider: Arc::new(Hang),
359 request: title_request("task", "m"),
360 permit: pools.try_acquire("m").expect("free"),
361 },
362 std::time::Duration::from_millis(5),
363 tx,
364 Arc::new(Notify::new()),
365 )
366 .await;
367 let outcome = rx.recv().await.expect("an outcome is always reported");
368 let err = outcome.result.expect_err("the deadline must surface");
369 assert!(err.to_string().contains("deadline"), "{err}");
370 }
371
372 #[tokio::test]
373 async fn dispatch_and_collect_set_the_title() {
374 let (mut world, title_rx) = build_world(Ok("\"Release notes digest\"\n"), default_pools());
375 world.insert_resource(TitleSettings(config(None, None)));
376 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
377
378 run_dispatch(&mut world);
379 assert!(world.get::<PendingTitle>(e).is_none());
380 assert!(world.get::<AwaitingTitle>(e).is_some());
381
382 let mut title_rx = title_rx;
385 let outcome = title_rx.recv().await.expect("job reported");
386 assert_eq!(outcome.entity, e);
387 let (tx, rx) = mpsc::unbounded_channel();
388 tx.send(outcome).unwrap();
389 world.insert_resource(TitleResults(rx));
390
391 run_collect(&mut world);
392 assert_eq!(
393 world.get::<RunMetadata>(e).unwrap().title.as_deref(),
394 Some("Release notes digest")
395 );
396 assert!(world.get::<AwaitingTitle>(e).is_none());
397 }
398
399 #[tokio::test]
400 async fn provider_error_leaves_the_title_unset() {
401 let (mut world, mut title_rx) = build_world(Err("boom"), default_pools());
402 world.insert_resource(TitleSettings(config(None, None)));
403 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
404
405 run_dispatch(&mut world);
406 let outcome = title_rx.recv().await.expect("job reported");
407 assert!(outcome.result.is_err());
408 let (tx, rx) = mpsc::unbounded_channel();
409 tx.send(outcome).unwrap();
410 world.insert_resource(TitleResults(rx));
411
412 run_collect(&mut world);
413 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
414 assert!(world.get::<AwaitingTitle>(e).is_none());
415 }
416
417 #[tokio::test]
418 async fn whitespace_reply_leaves_the_title_unset() {
419 let (mut world, mut title_rx) = build_world(Ok(" \n \n"), default_pools());
420 world.insert_resource(TitleSettings(config(None, None)));
421 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
422
423 run_dispatch(&mut world);
424 let outcome = title_rx.recv().await.expect("job reported");
425 let (tx, rx) = mpsc::unbounded_channel();
426 tx.send(outcome).unwrap();
427 world.insert_resource(TitleResults(rx));
428
429 run_collect(&mut world);
430 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
431 }
432
433 #[tokio::test]
434 async fn collect_skips_a_despawned_agent() {
435 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
436 let (tx, rx) = mpsc::unbounded_channel();
437 let ghost = world.spawn(metadata(Some("mock/m"))).id();
439 world.despawn(ghost);
440 tx.send(TitleOutcome {
441 entity: ghost,
442 result: Ok("t".to_string()),
443 })
444 .unwrap();
445 world.insert_resource(TitleResults(rx));
446 run_collect(&mut world); }
448
449 #[tokio::test]
450 async fn dispatch_without_settings_drops_the_marker() {
451 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
452 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
453 run_dispatch(&mut world);
454 assert!(world.get::<PendingTitle>(e).is_none());
455 assert!(world.get::<AwaitingTitle>(e).is_none());
456 }
457
458 #[tokio::test]
459 async fn dispatch_with_disabled_settings_drops_the_marker() {
460 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
461 world.insert_resource(TitleSettings(leviath_core::config::TitleConfig {
462 enabled: false,
463 provider: None,
464 model: None,
465 }));
466 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
467 run_dispatch(&mut world);
468 assert!(world.get::<PendingTitle>(e).is_none());
469 assert!(world.get::<AwaitingTitle>(e).is_none());
470 }
471
472 #[tokio::test]
473 async fn dispatch_with_unregistered_provider_drops_the_marker() {
474 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
475 world.insert_resource(TitleSettings(config(Some("nowhere"), Some("m"))));
476 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
477 run_dispatch(&mut world);
478 assert!(world.get::<PendingTitle>(e).is_none());
479 assert!(world.get::<AwaitingTitle>(e).is_none());
480 }
481
482 #[tokio::test]
483 async fn dispatch_retries_while_the_pool_is_full() {
484 let mut cfg = crate::inference_pool::InferencePoolConfig::new();
485 cfg.set_limit("m", 1);
486 let pools = crate::inference_pool::InferencePools::new(cfg);
487 let held = pools.try_acquire("m").unwrap();
488 let (mut world, _title_rx) = build_world(Ok("t"), pools);
489 world.insert_resource(TitleSettings(config(None, None)));
490 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
491
492 run_dispatch(&mut world);
493 assert!(world.get::<PendingTitle>(e).is_some());
495 assert!(world.get::<AwaitingTitle>(e).is_none());
496 drop(held);
497 }
498
499 fn config(provider: Option<&str>, model: Option<&str>) -> leviath_core::config::TitleConfig {
500 leviath_core::config::TitleConfig {
501 enabled: true,
502 provider: provider.map(str::to_string),
503 model: model.map(str::to_string),
504 }
505 }
506
507 #[test]
508 fn resolve_prefers_the_configured_pair() {
509 assert_eq!(
510 resolve_title_model(
511 &config(Some("openai"), Some("gpt-5-mini")),
512 Some("anthropic/m")
513 ),
514 Some(("openai".to_string(), "gpt-5-mini".to_string()))
515 );
516 }
517
518 #[test]
519 fn resolve_falls_back_to_the_runs_provider_and_model() {
520 assert_eq!(
521 resolve_title_model(&config(None, None), Some("anthropic/claude-x")),
522 Some(("anthropic".to_string(), "claude-x".to_string()))
523 );
524 }
525
526 #[test]
527 fn resolve_borrows_the_runs_model_only_for_the_same_provider() {
528 assert_eq!(
529 resolve_title_model(&config(Some("anthropic"), None), Some("anthropic/claude-x")),
530 Some(("anthropic".to_string(), "claude-x".to_string()))
531 );
532 assert_eq!(
534 resolve_title_model(&config(Some("openai"), None), Some("anthropic/claude-x")),
535 None
536 );
537 }
538
539 #[test]
540 fn resolve_gives_up_without_any_provider_or_model() {
541 assert_eq!(resolve_title_model(&config(None, None), None), None);
542 assert_eq!(resolve_title_model(&config(None, Some("m")), None), None);
543 assert_eq!(
545 resolve_title_model(&config(None, None), Some("bare-label")),
546 None
547 );
548 }
549
550 #[test]
551 fn title_request_truncates_the_task_and_carries_the_model() {
552 let long_task = "x".repeat(5_000);
553 let req = title_request(&long_task, "gpt-5-mini");
554 assert_eq!(req.model, "gpt-5-mini");
555 assert_eq!(req.max_tokens, TITLE_MAX_TOKENS);
556 assert_eq!(req.messages.len(), 2);
557 let expected: leviath_providers::MessageContent =
558 leviath_core::truncate_at_boundary(&long_task, TITLE_TASK_BUDGET)
559 .to_string()
560 .into();
561 assert_eq!(req.messages[1].content, expected);
562 }
563
564 #[tokio::test]
565 async fn scripted_provider_metadata_is_exercised() {
566 let p = Scripted(Ok("t"));
568 assert_eq!(p.name(), "mock");
569 assert_eq!(p.count_tokens("t", "m").await, 1);
570 assert_eq!(p.max_context_tokens("m"), 100_000);
571 let default = leviath_providers::ModelCapabilities::default();
572 assert_eq!(
573 p.capabilities("m").max_output_tokens,
574 default.max_output_tokens
575 );
576 }
577
578 #[test]
579 fn sanitize_takes_the_first_line_unquoted_and_capped() {
580 assert_eq!(
581 sanitize_title("\"Fix the login bug\"\nextra"),
582 "Fix the login bug"
583 );
584 assert_eq!(
585 sanitize_title("\n\n 'Tidy: workspace' \n"),
586 "Tidy: workspace"
587 );
588 assert_eq!(sanitize_title(" \n\t\n"), "");
589 let long = "word ".repeat(40);
590 assert!(sanitize_title(&long).len() <= TITLE_MAX_LEN);
591 }
592}