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
123type TitleQuery = (Entity, &'static RunMetadata);
128
129pub fn dispatch_title(
135 agents: Query<TitleQuery, (With<PendingTitle>, Without<AwaitingTitle>)>,
136 settings: Option<Res<TitleSettings>>,
137 stage: Res<InferenceStage>,
138 providers: Res<Providers>,
139 sink: Res<TitleSink>,
140 mut commands: Commands,
141) {
142 crate::tick_scope::clear();
143 for (entity, meta) in agents.iter() {
144 crate::tick_scope::enter(entity);
145 let resolved = settings
146 .as_ref()
147 .filter(|s| s.0.enabled)
148 .and_then(|s| resolve_title_model(&s.0, meta.model.as_deref()));
149 let Some((provider_name, model)) = resolved else {
150 tracing::debug!(run_id = %meta.run_id, "no usable title provider/model; skipping");
151 commands.entity(entity).remove::<PendingTitle>();
152 continue;
153 };
154 let Some(provider) = providers.0.get(&provider_name) else {
155 tracing::debug!(
156 run_id = %meta.run_id,
157 provider = %provider_name,
158 "title provider not registered; skipping"
159 );
160 commands.entity(entity).remove::<PendingTitle>();
161 continue;
162 };
163 let Some(permit) = stage.pools.try_acquire(&model) else {
164 continue; };
166
167 stage.runtime.spawn(run_title_job(
168 TitleJob {
169 entity,
170 provider,
171 request: title_request(&meta.task, &model),
172 permit,
173 },
174 std::time::Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
175 sink.0.clone(),
176 stage.wake.clone(),
177 ));
178 commands
179 .entity(entity)
180 .remove::<PendingTitle>()
181 .insert(AwaitingTitle);
182 }
183}
184
185pub fn collect_title(
189 mut results: ResMut<TitleResults>,
190 mut agents: Query<&mut RunMetadata, With<AwaitingTitle>>,
191 mut commands: Commands,
192) {
193 crate::tick_scope::clear();
194 while let Ok(outcome) = results.0.try_recv() {
195 let Ok(mut meta) = agents.get_mut(outcome.entity) else {
196 continue; };
198 crate::tick_scope::enter(outcome.entity);
199 if let Ok(raw) = outcome.result {
200 let title = sanitize_title(&raw);
201 if !title.is_empty() {
202 meta.title = Some(title);
203 }
204 }
205 commands.entity(outcome.entity).remove::<AwaitingTitle>();
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 use bevy_ecs::schedule::Schedule;
213 use bevy_ecs::world::World;
214 use leviath_providers::{Provider, ProviderError};
215 use std::sync::Arc;
216 use tokio::runtime::Handle;
217 use tokio::sync::Notify;
218 use tokio::sync::mpsc;
219
220 struct Scripted(Result<&'static str, &'static str>);
222
223 #[async_trait::async_trait]
224 impl Provider for Scripted {
225 async fn infer(
226 &self,
227 _r: &InferenceRequest,
228 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
229 match self.0 {
230 Ok(reply) => Ok(leviath_providers::InferenceResponse {
231 content: reply.to_string(),
232 tool_calls: vec![],
233 tokens_used: leviath_providers::TokenUsage {
234 prompt_tokens: 1,
235 completion_tokens: 1,
236 total_tokens: 2,
237 cached_tokens: 0,
238 cache_write_tokens: 0,
239 },
240 finish_reason: leviath_providers::FinishReason::Complete,
241 }),
242 Err(msg) => Err(ProviderError::Other(msg.to_string())),
243 }
244 }
245 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
246 1
247 }
248 fn max_context_tokens(&self, _m: &str) -> usize {
249 100_000
250 }
251 fn name(&self) -> &str {
252 "mock"
253 }
254 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
255 leviath_providers::ModelCapabilities::default()
256 }
257 }
258
259 fn metadata(model: Option<&str>) -> RunMetadata {
260 RunMetadata {
261 run_id: "run-t".to_string(),
262 agent_name: "titled".to_string(),
263 agent_path: "/a".to_string(),
264 task: "summarize the release notes".to_string(),
265 model: model.map(str::to_string),
266 workdir: "/w".to_string(),
267 num_stages: 1,
268 started_at: 0,
269 parent_run_id: None,
270 metadata: Default::default(),
271 callback_url: None,
272 callback_secret: None,
273 title: None,
274 unattended: false,
275 read_paths: None,
276 output_request: None,
277 }
278 }
279
280 fn build_world(
283 reply: Result<&'static str, &'static str>,
284 pools: crate::inference_pool::InferencePools,
285 ) -> (World, mpsc::UnboundedReceiver<TitleOutcome>) {
286 let mut registry = crate::ProviderRegistry::new();
287 registry.register("mock".to_string(), Arc::new(Scripted(reply)));
288 let (title_tx, title_rx) = mpsc::unbounded_channel();
289 let (inf_tx, _inf_rx) = mpsc::unbounded_channel();
290 let (ttx, _trx) = mpsc::unbounded_channel();
291 let (ctx, _crx) = mpsc::unbounded_channel();
292 let (cstx, _csrx) = mpsc::unbounded_channel();
293 let mut world = World::new();
294 world.insert_resource(Providers(registry));
295 world.insert_resource(InferenceStage {
296 pools: Arc::new(pools),
297 outcomes: inf_tx,
298 transition_outcomes: ttx,
299 compaction_outcomes: ctx,
300 content_summary_outcomes: cstx,
301 wake: Arc::new(Notify::new()),
302 runtime: Handle::current(),
303 exact_token_counting: false,
304 });
305 world.insert_resource(TitleSink(title_tx));
306 (world, title_rx)
307 }
308
309 fn run_dispatch(world: &mut World) {
310 let mut schedule = Schedule::default();
311 schedule.add_systems(dispatch_title);
312 schedule.run(world);
313 }
314
315 fn run_collect(world: &mut World) {
316 let mut schedule = Schedule::default();
317 schedule.add_systems(collect_title);
318 schedule.run(world);
319 }
320
321 fn default_pools() -> crate::inference_pool::InferencePools {
322 crate::inference_pool::InferencePools::new(crate::inference_pool::InferencePoolConfig::new())
323 }
324
325 #[tokio::test]
328 async fn title_deadline_frees_the_slot_when_the_provider_hangs() {
329 struct Hang;
330 #[async_trait::async_trait]
331 impl Provider for Hang {
332 async fn infer(
333 &self,
334 _r: &InferenceRequest,
335 ) -> leviath_providers::Result<leviath_providers::InferenceResponse> {
336 std::future::pending().await
337 }
338 async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
339 1
340 }
341 fn max_context_tokens(&self, _m: &str) -> usize {
342 100_000
343 }
344 fn name(&self) -> &str {
345 "hang"
346 }
347 fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
348 leviath_providers::ModelCapabilities::default()
349 }
350 }
351
352 assert_eq!(Hang.count_tokens("t", "m").await, 1);
354 assert_eq!(Hang.max_context_tokens("m"), 100_000);
355 assert_eq!(Hang.name(), "hang");
356 let _ = Hang.capabilities("m");
357 let pools = crate::inference_pool::InferencePools::new(
358 crate::inference_pool::InferencePoolConfig::new(),
359 );
360 let (tx, mut rx) = mpsc::unbounded_channel();
361 run_title_job(
362 TitleJob {
363 entity: bevy_ecs::entity::Entity::PLACEHOLDER,
364 provider: Arc::new(Hang),
365 request: title_request("task", "m"),
366 permit: pools.try_acquire("m").expect("free"),
367 },
368 std::time::Duration::from_millis(5),
369 tx,
370 Arc::new(Notify::new()),
371 )
372 .await;
373 let outcome = rx.recv().await.expect("an outcome is always reported");
374 let err = outcome.result.expect_err("the deadline must surface");
375 assert!(err.to_string().contains("deadline"), "{err}");
376 }
377
378 #[tokio::test]
379 async fn dispatch_and_collect_set_the_title() {
380 let (mut world, title_rx) = build_world(Ok("\"Release notes digest\"\n"), default_pools());
381 world.insert_resource(TitleSettings(config(None, None)));
382 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
383
384 run_dispatch(&mut world);
385 assert!(world.get::<PendingTitle>(e).is_none());
386 assert!(world.get::<AwaitingTitle>(e).is_some());
387
388 let mut title_rx = title_rx;
391 let outcome = title_rx.recv().await.expect("job reported");
392 assert_eq!(outcome.entity, e);
393 let (tx, rx) = mpsc::unbounded_channel();
394 tx.send(outcome).unwrap();
395 world.insert_resource(TitleResults(rx));
396
397 run_collect(&mut world);
398 assert_eq!(
399 world.get::<RunMetadata>(e).unwrap().title.as_deref(),
400 Some("Release notes digest")
401 );
402 assert!(world.get::<AwaitingTitle>(e).is_none());
403 }
404
405 #[tokio::test]
406 async fn provider_error_leaves_the_title_unset() {
407 let (mut world, mut title_rx) = build_world(Err("boom"), default_pools());
408 world.insert_resource(TitleSettings(config(None, None)));
409 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
410
411 run_dispatch(&mut world);
412 let outcome = title_rx.recv().await.expect("job reported");
413 assert!(outcome.result.is_err());
414 let (tx, rx) = mpsc::unbounded_channel();
415 tx.send(outcome).unwrap();
416 world.insert_resource(TitleResults(rx));
417
418 run_collect(&mut world);
419 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
420 assert!(world.get::<AwaitingTitle>(e).is_none());
421 }
422
423 #[tokio::test]
424 async fn whitespace_reply_leaves_the_title_unset() {
425 let (mut world, mut title_rx) = build_world(Ok(" \n \n"), default_pools());
426 world.insert_resource(TitleSettings(config(None, None)));
427 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
428
429 run_dispatch(&mut world);
430 let outcome = title_rx.recv().await.expect("job reported");
431 let (tx, rx) = mpsc::unbounded_channel();
432 tx.send(outcome).unwrap();
433 world.insert_resource(TitleResults(rx));
434
435 run_collect(&mut world);
436 assert_eq!(world.get::<RunMetadata>(e).unwrap().title, None);
437 }
438
439 #[tokio::test]
440 async fn collect_skips_a_despawned_agent() {
441 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
442 let (tx, rx) = mpsc::unbounded_channel();
443 let ghost = world.spawn(metadata(Some("mock/m"))).id();
445 world.despawn(ghost);
446 tx.send(TitleOutcome {
447 entity: ghost,
448 result: Ok("t".to_string()),
449 })
450 .unwrap();
451 world.insert_resource(TitleResults(rx));
452 run_collect(&mut world); }
454
455 #[tokio::test]
456 async fn dispatch_without_settings_drops_the_marker() {
457 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
458 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
459 run_dispatch(&mut world);
460 assert!(world.get::<PendingTitle>(e).is_none());
461 assert!(world.get::<AwaitingTitle>(e).is_none());
462 }
463
464 #[tokio::test]
465 async fn dispatch_with_disabled_settings_drops_the_marker() {
466 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
467 world.insert_resource(TitleSettings(leviath_core::config::TitleConfig {
468 enabled: false,
469 provider: None,
470 model: None,
471 }));
472 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
473 run_dispatch(&mut world);
474 assert!(world.get::<PendingTitle>(e).is_none());
475 assert!(world.get::<AwaitingTitle>(e).is_none());
476 }
477
478 #[tokio::test]
479 async fn dispatch_with_unregistered_provider_drops_the_marker() {
480 let (mut world, _title_rx) = build_world(Ok("t"), default_pools());
481 world.insert_resource(TitleSettings(config(Some("nowhere"), Some("m"))));
482 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
483 run_dispatch(&mut world);
484 assert!(world.get::<PendingTitle>(e).is_none());
485 assert!(world.get::<AwaitingTitle>(e).is_none());
486 }
487
488 #[tokio::test]
489 async fn dispatch_retries_while_the_pool_is_full() {
490 let mut cfg = crate::inference_pool::InferencePoolConfig::new();
491 cfg.set_limit("m", 1);
492 let pools = crate::inference_pool::InferencePools::new(cfg);
493 let held = pools.try_acquire("m").unwrap();
494 let (mut world, _title_rx) = build_world(Ok("t"), pools);
495 world.insert_resource(TitleSettings(config(None, None)));
496 let e = world.spawn((metadata(Some("mock/m")), PendingTitle)).id();
497
498 run_dispatch(&mut world);
499 assert!(world.get::<PendingTitle>(e).is_some());
501 assert!(world.get::<AwaitingTitle>(e).is_none());
502 drop(held);
503 }
504
505 fn config(provider: Option<&str>, model: Option<&str>) -> leviath_core::config::TitleConfig {
506 leviath_core::config::TitleConfig {
507 enabled: true,
508 provider: provider.map(str::to_string),
509 model: model.map(str::to_string),
510 }
511 }
512
513 #[test]
514 fn resolve_prefers_the_configured_pair() {
515 assert_eq!(
516 resolve_title_model(
517 &config(Some("openai"), Some("gpt-5-mini")),
518 Some("anthropic/m")
519 ),
520 Some(("openai".to_string(), "gpt-5-mini".to_string()))
521 );
522 }
523
524 #[test]
525 fn resolve_falls_back_to_the_runs_provider_and_model() {
526 assert_eq!(
527 resolve_title_model(&config(None, None), Some("anthropic/claude-x")),
528 Some(("anthropic".to_string(), "claude-x".to_string()))
529 );
530 }
531
532 #[test]
533 fn resolve_borrows_the_runs_model_only_for_the_same_provider() {
534 assert_eq!(
535 resolve_title_model(&config(Some("anthropic"), None), Some("anthropic/claude-x")),
536 Some(("anthropic".to_string(), "claude-x".to_string()))
537 );
538 assert_eq!(
540 resolve_title_model(&config(Some("openai"), None), Some("anthropic/claude-x")),
541 None
542 );
543 }
544
545 #[test]
546 fn resolve_gives_up_without_any_provider_or_model() {
547 assert_eq!(resolve_title_model(&config(None, None), None), None);
548 assert_eq!(resolve_title_model(&config(None, Some("m")), None), None);
549 assert_eq!(
551 resolve_title_model(&config(None, None), Some("bare-label")),
552 None
553 );
554 }
555
556 #[test]
557 fn title_request_truncates_the_task_and_carries_the_model() {
558 let long_task = "x".repeat(5_000);
559 let req = title_request(&long_task, "gpt-5-mini");
560 assert_eq!(req.model, "gpt-5-mini");
561 assert_eq!(req.max_tokens, TITLE_MAX_TOKENS);
562 assert_eq!(req.messages.len(), 2);
563 let expected: leviath_providers::MessageContent =
564 leviath_core::truncate_at_boundary(&long_task, TITLE_TASK_BUDGET)
565 .to_string()
566 .into();
567 assert_eq!(req.messages[1].content, expected);
568 }
569
570 #[tokio::test]
571 async fn scripted_provider_metadata_is_exercised() {
572 let p = Scripted(Ok("t"));
574 assert_eq!(p.name(), "mock");
575 assert_eq!(p.count_tokens("t", "m").await, 1);
576 assert_eq!(p.max_context_tokens("m"), 100_000);
577 let default = leviath_providers::ModelCapabilities::default();
578 assert_eq!(
579 p.capabilities("m").max_output_tokens,
580 default.max_output_tokens
581 );
582 }
583
584 #[test]
585 fn sanitize_takes_the_first_line_unquoted_and_capped() {
586 assert_eq!(
587 sanitize_title("\"Fix the login bug\"\nextra"),
588 "Fix the login bug"
589 );
590 assert_eq!(
591 sanitize_title("\n\n 'Tidy: workspace' \n"),
592 "Tidy: workspace"
593 );
594 assert_eq!(sanitize_title(" \n\t\n"), "");
595 let long = "word ".repeat(40);
596 assert!(sanitize_title(&long).len() <= TITLE_MAX_LEN);
597 }
598}