Skip to main content

leviath_runtime/
title.rs

1//! One-shot run-title generation.
2//!
3//! The dashboard displays, searches, and persists `RunMetadata.title`; this
4//! module is what fills it in. At spawn, the daemon marks an eligible run
5//! [`PendingTitle`]; [`dispatch_title`] makes one cheap LLM call over the
6//! task prompt via the `title_bridge` worker, and [`collect_title`]
7//! sanitizes the reply into the metadata. Everything downstream (persistence,
8//! dashboard header, run search) already reads the field.
9//!
10//! Best-effort by design: any failure - no usable provider or model, a full
11//! pool that never frees, a provider error, an empty reply - leaves the title
12//! `None` and the run displays its blueprint name exactly as before. The
13//! title lands on disk with the next persistence write (the run-level
14//! heartbeat guarantees one within a few seconds).
15
16use 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/// The `[title]` config, as a world resource (inserted by the daemon at
25/// setup). Absent in worlds that never title (tests, `lev run` without it).
26#[derive(Resource, Clone)]
27pub struct TitleSettings(pub leviath_core::config::TitleConfig);
28
29/// This run wants a title; `dispatch_title` picks it up on the next tick.
30/// Inserted at spawn for enabled, root, non-empty-task runs only.
31#[derive(Component, Debug, Clone, Copy)]
32pub struct PendingTitle;
33
34/// A title call is in flight. Unlike `AwaitingCompaction`, this does not hold
35/// the agent out of inference - titling runs alongside the first turn.
36#[derive(Component, Debug, Clone, Copy)]
37pub struct AwaitingTitle;
38
39/// The receiving end of the title-outcomes channel, as a world resource.
40#[derive(Resource)]
41pub struct TitleResults(pub UnboundedReceiver<TitleOutcome>);
42
43/// The sending end, cloned into each spawned title job.
44#[derive(Resource)]
45pub struct TitleSink(pub UnboundedSender<TitleOutcome>);
46
47/// Kept small: a title is one short line, and a runaway reply is cut anyway.
48const TITLE_MAX_TOKENS: usize = 64;
49/// How much of the task prompt the model sees. Titles come from the opening
50/// framing of a task, not its appendix.
51const TITLE_TASK_BUDGET: usize = 2_000;
52/// Display cap, in bytes, cut on a char boundary.
53const 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
58/// Resolve which provider/model the title call should use.
59///
60/// `[title]` config wins where set; unset fields fall back to the run's own
61/// first-stage provider and model (from the `provider/model` label). The one
62/// unguessable case - an explicit title provider that differs from the run's,
63/// with no title model - resolves to `None`, because the run's model name
64/// means nothing to another provider.
65fn 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
81/// Build the one-shot titling request over the task prompt.
82fn 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
108/// Reduce a raw model reply to a displayable one-line title: the first
109/// non-empty line, unquoted, capped at [`TITLE_MAX_LEN`]. Empty means "no
110/// title" and the metadata stays untouched.
111fn 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/// What `dispatch_title` selects.
124///
125/// `&'static` is bevy's `WorldQuery` convention, not a claim about
126/// lifetimes: the borrow is bound when the query is fetched.
127type TitleQuery = (Entity, &'static RunMetadata);
128
129/// Dispatch system: start the title call for each [`PendingTitle`] run.
130///
131/// A full pool leaves the marker in place to retry next tick; every other
132/// dead end (no settings resource, no resolvable provider/model, provider
133/// not registered) drops the marker so the query empties instead of spinning.
134pub 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; // pool full - retry next tick
165        };
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
185/// Collect system: store each finished title into its run's metadata. A
186/// provider error or empty reply changes nothing; either way the in-flight
187/// marker comes off.
188pub 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; // stale: agent cancelled/despawned since dispatch
197        };
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    /// A provider whose single call yields a fixed reply or a fixed error.
221    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    /// A world with the title lane wired over the given provider outcome, plus
281    /// the receiver the dispatched job reports into.
282    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    /// The permit must come back within the deadline even when the provider
326    /// never answers - a hung title call once held its pool slot forever.
327    #[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        // Trait obligations on the mock; only `infer` matters to this test.
353        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        // Await the spawned job's report, then hand it to the collector
389        // through the results resource.
390        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        // A ghost entity id: spawn then despawn.
444        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); // must not panic
453    }
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        // Slot occupied: the marker stays so the next tick retries.
500        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        // A different provider cannot use the run's model name.
539        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        // A label without a slash carries no provider/model split.
550        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        // Keep the mock's non-`infer` trait methods measured.
573        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}