Skip to main content

agentmux_srv/ambient/
tasks.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The background and one-shot ambient Haiku callers: pushed and per-definition
5//! activity summaries, subagent and workflow-batch names, and narration. Each is
6//! best-effort — every failure path returns `None` and the caller carries on
7//! without. They all go through `call::admit` / `Slot::run`, so admission,
8//! cancellation, concurrency caps and reply validation are the same for each.
9//!
10//! The two pull RPCs (`session:activity_summary`, `session:next_prompt_suggestion`)
11//! use the same path from `server::app_api::session`.
12
13use std::sync::Arc;
14
15use super::call::{self, CliTarget, Reply};
16use super::reply::judge_line;
17use super::limits::Class;
18use super::{digest, prompt, purpose, validate};
19use crate::agents::TokenCounts;
20use crate::backend::obj::{Block, MetaMapType};
21use crate::backend::storage::store::Store;
22
23/// What a background call produced. `None` from a task means it did not run to
24/// completion (superseded, capped out, nothing to send, CLI failed), so a caller
25/// that tracks progress should try again later. `Some` means the CLI ran: `text`
26/// is `None` when the model had nothing usable to say (the reply failed
27/// validation, or was empty), which is a finished attempt, not a failure, and
28/// `tokens` is the spend either way.
29#[derive(Debug)]
30pub(crate) struct Generated {
31    pub text: Option<String>,
32    pub tokens: Option<TokenCounts>,
33}
34
35fn finish(reply: Reply) -> Option<Generated> {
36    if reply.error.is_some() {
37        return None;
38    }
39    let text = (!reply.text.is_empty()).then_some(reply.text);
40    Some(Generated { text, tokens: reply.tokens })
41}
42
43/// Read-only CLI path lookup for `provider_id` — checks the versioned
44/// local-install dir, then falls back to system PATH. Deliberately never
45/// installs anything (unlike the `resolvecli` RPC handler / `agent_open.rs`'s
46/// launch-time resolution, which both trigger an npm install as a fallback):
47/// this is a best-effort BACKGROUND call, not a user-initiated launch —
48/// silently installing a CLI as a side effect of a picker preview summary
49/// would be a surprising, unwanted cost. Returns `None` (not an error) for
50/// an unknown provider or a CLI that isn't already available either way;
51/// the caller treats that the same as any other unresolvable case.
52pub async fn resolve_provider_cli_path_readonly(provider_id: &str) -> Option<String> {
53    let provider = crate::backend::providers::get_provider(provider_id)?;
54    let paths = agentmux_common::DataPaths::from_env()?;
55    if let Some(bin) = crate::backend::cli_install::find_installed_for_provider(&paths, provider.id) {
56        return Some(bin.to_string_lossy().to_string());
57    }
58    crate::server::cli_handlers::resolve_provider_cli_on_path(provider.id, provider.cli_command).await
59}
60
61/// A session title recovered from the session's recent activity, for an agent
62/// that has none — called by the background sweep in
63/// `backend::reactive::activity_watcher`, which writes an accepted result to
64/// `term:ambient_summary` itself. Goes through the same Ambient Model Call gateway
65/// (admission, cancellation-of-superseded, token accounting) under the distinct
66/// `purpose::ACTIVITY_SUMMARY_PUSHED` purpose, so it never contends with the
67/// pane's own title request. The prompt is the pane's own title prompt
68/// (`build_session_title_prompt`), given the activity instead of a message, not a
69/// "what is happening now" summary: the result is shown as the session's title.
70///
71/// `generation` only needs to strictly increase across successive calls for
72/// the *same* `block_id` — the sweep loop's tick counter is sufficient; it
73/// doesn't need to correlate with the pull path's per-turn generation.
74///
75/// Returns `None` when there's nothing to summarize yet, the block/CLI path
76/// isn't resolvable, this call was superseded, or the CLI failed — the
77/// caller treats all of these as "no summary this tick."
78pub(crate) async fn generate_recovered_title(
79    mstore: &Store,
80    filestore: &crate::backend::storage::filestore::FileStore,
81    block_id: &str,
82    generation: u64,
83    word_target: u32,
84) -> Option<Generated> {
85    let word_target = word_target.max(3).min(20);
86
87    let slot = call::admit(&purpose::ACTIVITY_SUMMARY_PUSHED, block_id, generation).await?;
88
89    let block: Block = mstore.get(block_id).ok().flatten()?;
90    let Some(digest) = digest::read_recent_activity_digest(filestore, block_id) else {
91        slot.abandon(super::outcome::Outcome::EmptyDigest);
92        return None;
93    };
94    let target = CliTarget::from_meta(&block.meta)?;
95
96    let prompt = prompt::build_session_title_prompt("", None, Some(&digest), word_target);
97    let limits = validate::title_limits(word_target);
98    finish(slot.run(&target, &prompt, |raw| judge_line(raw, |t| validate::accept_line(t, &limits))).await)
99}
100
101/// Generate (and persist) a short activity summary for a definition whose
102/// AgentPicker row has no structured `output.state.json` conversation
103/// snapshot to preview — legacy rows predating snapshot persistence, per
104/// `has_snapshot` in `agent_handlers::session::listrecentsessions`. Built
105/// from the instance's raw terminal capture (the `"output"` filestore file,
106/// written unconditionally by the CLI pipeline, independent of the newer
107/// structured snapshot), reusing `read_recent_activity_digest` — the same
108/// extraction `generate_recovered_title` above uses.
109///
110/// One-shot per definition, not per-turn: `generation` is always the
111/// constant `1`, mirroring `generate_subagent_name`'s cache-once posture —
112/// the caller only invokes this when `agent_activity_summary_get` found
113/// nothing persisted yet, so there is no "newer turn" to supersede an
114/// in-flight call here (unlike the `session:activity_summary` RPC, which
115/// regenerates every turn for a LIVE conversation).
116///
117/// CLI path resolution (reagent P1, PR #2786): the row shape this feature
118/// actually targets is a CLOSED pane — `DeleteBlock`
119/// (`sagas::delete_block::run`) removes the `Block` row entirely on pane
120/// close while the instance row and raw filestore output survive. For such
121/// rows there is no live block to read `cmd`/`cmd:env` meta from at all, so
122/// this prefers the block record when it still exists (richer: carries the
123/// per-block auth env the CLI needs) and falls back to
124/// `resolve_provider_cli_path_readonly` + an EMPTY auth env otherwise —
125/// best-effort: a provider that needs per-block injected credentials (no
126/// ambient/global login available) fails the Haiku call cleanly (`None`,
127/// same as any other unresolvable case here), not a wrong result.
128///
129/// Fire-and-forget by design: the caller (`listrecentsessions`) spawns this
130/// in the background and does not await it inline. The row that triggered
131/// generation still shows its existing fallback text on THIS response; on
132/// success this broadcasts `agents:changed`, which `MyAgentsList.tsx`
133/// already refetches on, picking up the now-persisted summary on the next
134/// load. Returns `None` (nothing persisted, nothing broadcast) when there's
135/// no raw output to summarize, the CLI path isn't resolvable, this call was
136/// superseded/capped, or the CLI failed — the caller treats all of these as
137/// "still nothing to show," not an error.
138pub(crate) async fn generate_definition_activity_summary(
139    mstore: &Store,
140    filestore: &crate::backend::storage::filestore::FileStore,
141    broker: &Arc<crate::backend::mps::Broker>,
142    definition_id: &str,
143    block_id: &str,
144    provider_id: &str,
145) -> Option<Generated> {
146    let slot = call::admit(&purpose::DEFINITION_SUMMARY, definition_id, 1).await?;
147
148    let digest = digest::read_recent_activity_digest(filestore, block_id)?;
149
150    let target = match mstore.get::<Block>(block_id) {
151        Ok(Some(block)) => match CliTarget::from_meta(&block.meta) {
152            Some(target) => target,
153            None => provider_fallback_target(provider_id).await?,
154        },
155        _ => provider_fallback_target(provider_id).await?,
156    };
157
158    let prompt = prompt::build_definition_summary_prompt(&digest);
159    let generated =
160        finish(slot.run(&target, &prompt, |raw| judge_line(raw, |t| validate::accept_line(t, &validate::PREVIEW))).await)?;
161    let Some(summary) = generated.text.clone() else {
162        return Some(generated);
163    };
164
165    let now = agentmux_common::time::now_ms();
166    match mstore.agent_activity_summary_set(definition_id, &summary, now) {
167        Ok(()) => {
168            broker.publish_event::<crate::backend::mps_events::AgentsChanged>(&(), vec![], 0);
169        }
170        Err(e) => {
171            // reagent P2, PR #2786: the caller's definition_summary_attempted()
172            // gate already permanently claims definition_id before spawning —
173            // a persistence failure here silently and permanently discards a
174            // successfully generated (and billed) summary with no diagnostic
175            // trail otherwise, unlike every other fallible store call this PR
176            // touches (agent_activity_summary_get's error path logs).
177            tracing::warn!(
178                error = %e,
179                definition_id = %definition_id,
180                "generate_definition_activity_summary: agent_activity_summary_set \
181                 failed — a successfully generated summary was discarded"
182            );
183        }
184    }
185
186    Some(generated)
187}
188
189/// The provider's own CLI with an EMPTY auth env, for a block that no longer
190/// exists or names no CLI. `None` when the provider's CLI isn't installed.
191async fn provider_fallback_target(provider_id: &str) -> Option<CliTarget> {
192    let cli_path = resolve_provider_cli_path_readonly(provider_id).await?;
193    Some(CliTarget { cli_path, meta: MetaMapType::new() })
194}
195
196/// Generate (or return the already-cached) concise Haiku display name for a
197/// subagent. Called on-demand the first time a client expands that
198/// subagent's row in the Swarm view — see `("subagent", "GenerateName")` in
199/// `server::service::misc`. Subagents have no `Block`/meta of their own, so
200/// this borrows the parent block's CLI path + auth env, and reads the
201/// subagent's own initial task prompt directly off its JSONL (available
202/// immediately even for a still-running subagent, unlike a transcript
203/// summary which needs output to summarize).
204///
205/// Returns `None` when there's nothing to name (unknown subagent, no task
206/// prompt on the first JSONL line, parent block unresolvable, or the call
207/// was superseded/capped/failed) — callers should treat that as "leave the
208/// row showing its slug/id fallback," not an error. A cache hit (name
209/// already generated) returns the cached name with `tokens: None` — there's
210/// no new spend to report.
211pub(crate) async fn generate_subagent_name(
212    mstore: &Store,
213    subagent_watcher: &Arc<crate::backend::subagent_watcher::SubagentWatcher>,
214    agent_id: &str,
215    class: Class,
216) -> Option<Generated> {
217    let info = subagent_watcher.get_info(agent_id)?;
218    if let Some(existing) = info.display_name {
219        return Some(Generated { text: Some(existing), tokens: None });
220    }
221
222    // Interactive for a live subagent or a row the user opened, background for
223    // the backlog pass over historical ones — see each call site.
224    // Checked again once admitted: the parent's description may have named it
225    // while this call waited.
226    let still_unnamed = || subagent_watcher.get_info(agent_id).is_some_and(|i| i.display_name.is_none());
227    let generated = generate_name_from_task_prompt(
228        mstore,
229        &info,
230        (&purpose::SUBAGENT_NAME, agent_id),
231        class,
232        prompt::build_subagent_name_prompt,
233        still_unnamed,
234    )
235    .await?;
236    if let Some(name) = &generated.text {
237        subagent_watcher.set_display_name(agent_id, name);
238    }
239    Some(generated)
240}
241
242/// Generate the one Haiku display name for a Workflow-kind dispatch,
243/// eagerly, the first time its first member is observed live, or for an
244/// unnamed historical dispatch via `SubagentWatcher::resolve_unnamed_backlog`
245/// when the Swarm pane opens (never called for a Solo dispatch — a Solo
246/// dispatch's name IS its one member's `display_name`, already covered by
247/// `generate_subagent_name`; never called during cold-backfill replay — see
248/// the `live` gate on `process_jsonl_change`'s `trigger_eager_naming` call in
249/// `crates/srv/src/backend/subagent_watcher/jsonl.rs`).
250///
251/// A workflow has no single task prompt the way a solo call does (members
252/// can have different prompts) — this reads `first_member_agent_id`'s own
253/// task prompt via the same `read_task_prompt()` `generate_subagent_name`
254/// uses, on the resolved design basis that the first member's prompt is a
255/// reasonable stand-in for the whole batch (SPEC §3 — not a perfect
256/// representation of every member's task, an accepted v1 trade-off).
257///
258/// Otherwise runs the same `generate_name_from_task_prompt` pipeline as
259/// `generate_subagent_name` — no cache-hit short-circuit here
260/// (unlike that function): this is only ever called once per dispatch,
261/// already guarded by `naming_triggered` at the call site, so a cache check
262/// would be dead code, not a real fast path.
263pub(crate) async fn generate_dispatch_name(
264    mstore: &Store,
265    subagent_watcher: &Arc<crate::backend::subagent_watcher::SubagentWatcher>,
266    dispatch_id: &str,
267    first_member_agent_id: &str,
268    class: Class,
269) -> Option<Generated> {
270    let info = subagent_watcher.get_info(first_member_agent_id)?;
271
272    // Its class: see `generate_subagent_name`'s matching comment above.
273    let generated = generate_name_from_task_prompt(
274        mstore,
275        &info,
276        (&purpose::DISPATCH_NAME, dispatch_id),
277        class,
278        prompt::build_dispatch_name_prompt,
279        || true,
280    )
281    .await?;
282    if let Some(name) = &generated.text {
283        subagent_watcher.set_dispatch_name(dispatch_id, name);
284    }
285    Some(generated)
286}
287
288/// Admit `purpose` for `entity`, read `info`'s own task prompt off its JSONL,
289/// borrow its parent block's CLI, and run `build_prompt`'s prompt through the
290/// name check.
291async fn generate_name_from_task_prompt(
292    mstore: &Store,
293    info: &crate::backend::subagent_watcher::SubAgent,
294    (purpose, entity): (&'static purpose::Purpose, &str),
295    class: Class,
296    build_prompt: fn(&str) -> String,
297    still_needed: impl Fn() -> bool,
298) -> Option<Generated> {
299    let slot = call::admit_as(purpose, entity, 1, class).await?;
300    if !still_needed() {
301        slot.abandon(super::outcome::Outcome::Gated);
302        return None;
303    }
304
305    let task_prompt = crate::backend::subagent_watcher::read_task_prompt(&info.jsonl_path)?;
306    let block: Block = mstore.get(&info.parent_block_id).ok().flatten()?;
307    let target = CliTarget::from_meta(&block.meta)?;
308
309    let prompt = build_prompt(&prompt::clip_material(&task_prompt, TASK_HEAD_CHARS, TASK_TAIL_CHARS));
310    finish(slot.run(&target, &prompt, |raw| judge_line(raw, |t| validate::accept_line(t, &validate::NAME))).await)
311}
312
313/// How much of a subagent's task prompt a name call reads. Task prompts run to
314/// many kilobytes; what the task is comes first, and the whole prompt only made
315/// the call slower and gave the model more to mistake for its own instructions.
316const TASK_HEAD_CHARS: usize = 1200;
317const TASK_TAIL_CHARS: usize = 300;
318
319/// Generate a short user-facing line narrating an autonomous action.
320///
321/// Best-effort by construction — every failure path returns `None` and the
322/// caller simply says nothing. A UI state change must NEVER be gated on this:
323/// if Haiku is slow, capped out, or absent, the thing being narrated still
324/// happened and the UI must already reflect it.
325pub(crate) async fn generate_ambient_narration(
326    mstore: &Store,
327    block_id: &str,
328    event_id: &str,
329    generation: u64,
330    kind: &str,
331    context: &str,
332) -> Option<String> {
333    let prompt = prompt::narration_prompt(kind, context)?;
334
335    // Keyed on the EVENT, not the block. `admit()` cancels the prior in-flight
336    // call for a key as soon as a newer generation arrives — correct for the
337    // summary callers this is otherwise modelled on, where only the latest
338    // result is ever shown, and wrong here. Each narrated event is independent
339    // and all of them should arrive: two Bash calls backgrounded moments apart
340    // in one block are two separate facts, and `useAmbientNarration` retains
341    // several precisely because it expects them. Keying on the block alone let
342    // the second silently cancel the first, whose Haiku call then returned
343    // "cancelled" and was swallowed. (reagent P1 on #3169.)
344    let slot = call::admit(&purpose::NARRATION, format!("{block_id}:{event_id}"), generation).await?;
345
346    let block: Block = mstore.get(block_id).ok().flatten()?;
347    let target = CliTarget::from_meta(&block.meta)?;
348    let (text, _tokens) = slot
349        .run(&target, &prompt, |raw| judge_line(raw, |t| validate::accept_line(t, &validate::NARRATION)))
350        .await
351        .into_option()?;
352    Some(text)
353}
354
355/// How much of the continuation record the continued-session note reads: the
356/// running summary at its head, the latest exchange at its tail.
357const RECORD_HEAD_CHARS: usize = 6000;
358const RECORD_TAIL_CHARS: usize = 6000;
359
360/// The note a pane shows once a fresh session continues its conversation from
361/// AgentMux's `record` (`backend::continued_note`). Keyed on the block, so a
362/// newer spawn's note supersedes an older one still in flight. Best-effort:
363/// every failure is `None`, and the pane says nothing.
364pub(crate) async fn generate_continued_note(mstore: &Store, block_id: &str, generation: u64, record: &str) -> Option<String> {
365    let slot = call::admit(&purpose::SESSION_CONTINUED_NOTE, block_id, generation).await?;
366    let block: Block = mstore.get(block_id).ok().flatten()?;
367    let target = CliTarget::from_meta(&block.meta)?;
368    let prompt = prompt::continued_note_prompt(&prompt::clip_material(record, RECORD_HEAD_CHARS, RECORD_TAIL_CHARS));
369    let (text, _tokens) = slot
370        .run(&target, &prompt, |raw| judge_line(raw, |t| validate::accept_line(t, &validate::CONTINUED_NOTE)))
371        .await
372        .into_option()?;
373    Some(text)
374}
375
376/// reagent P1, PR #2786: `generate_definition_activity_summary` falls back
377/// to this resolver when the instance's `Block` row is gone (the closed-
378/// pane case this feature actually targets). Only the cheap, deterministic
379/// "unknown provider" early return is exercised here — the filesystem/PATH
380/// probing branches depend on this machine's actual CLI install state and
381/// aren't meaningfully unit-testable without mocking the filesystem.
382#[cfg(test)]
383mod resolve_provider_cli_path_readonly_tests {
384    use super::*;
385
386    #[tokio::test]
387    async fn unknown_provider_returns_none_without_touching_the_filesystem() {
388        assert!(resolve_provider_cli_path_readonly("not-a-real-provider-xyz").await.is_none());
389    }
390}
391
392#[cfg(test)]
393mod narration_key_tests {
394    use super::*;
395
396    #[test]
397    fn two_events_in_one_block_do_not_cancel_each_other() {
398        // The regression reagent caught on #3169. admit() cancels the prior
399        // in-flight call for a KEY, so keying narration on the block alone made
400        // a second backgrounded command in the same turn silently kill the
401        // first one's narration. Distinct event ids must be distinct keys.
402        use crate::ambient::{gateway, Admission, AmbientCallKey};
403
404        let first = AmbientCallKey::new("block-x:toolu_1", purpose::NARRATION.tag);
405        let second = AmbientCallKey::new("block-x:toolu_2", purpose::NARRATION.tag);
406        assert_ne!(first, second, "distinct events must not share a gateway key");
407
408        let g1 = match gateway().admit(first, 1) {
409            Admission::Proceed(g) => g,
410            Admission::StaleOnArrival => panic!("first should be admitted"),
411        };
412        let cancel1 = g1.cancellation();
413        let _g2 = match gateway().admit(second, 2) {
414            Admission::Proceed(g) => g,
415            Admission::StaleOnArrival => panic!("second should be admitted"),
416        };
417        // The second admission must leave the first alone.
418        assert!(!cancel1.is_cancelled(), "a sibling narration cancelled the first");
419    }
420}
421
422#[cfg(test)]
423mod finish_tests {
424    use super::*;
425
426    #[test]
427    fn a_failed_or_superseded_call_is_not_a_finished_attempt() {
428        let reply = Reply { text: String::new(), tokens: None, error: Some("cancelled".into()) };
429        assert!(finish(reply).is_none());
430    }
431
432    #[test]
433    fn a_rejected_reply_is_a_finished_attempt_that_keeps_its_spend() {
434        let tokens = Some(TokenCounts::default());
435        let reply = Reply { text: String::new(), tokens: tokens.clone(), error: None };
436        let generated = finish(reply).expect("the CLI ran");
437        assert!(generated.text.is_none());
438        assert!(generated.tokens.is_some());
439    }
440
441    #[test]
442    fn a_usable_reply_carries_its_text_and_spend() {
443        let reply = Reply { text: "Fix login".into(), tokens: Some(TokenCounts::default()), error: None };
444        let generated = finish(reply).unwrap();
445        assert_eq!(generated.text.as_deref(), Some("Fix login"));
446        assert!(generated.tokens.is_some());
447    }
448}