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}