diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 10c6c78d7..a1261c08e 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -31,6 +31,19 @@ use tokio::{runtime::Runtime, sync::mpsc}; const RETIRED_AFTER_UPDATE: &str = "Maple has been updated. Quit and reopen Maple to continue."; +/// How long a remote viewer waits for the host's optional capabilities +/// before summarizing that call from its output instead. +const TOOL_SUMMARY_PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + +/// What a tool summary asked as the call starts came back with. +#[derive(Debug, PartialEq, Eq)] +pub enum ToolStartSummary { + /// The label, or `None` when the model gave nothing usable. + Label(Option), + /// The remote host predates input-only summaries. + NeedsOutput, +} + pub struct AgentBackend { runtime: tokio::runtime::Handle, owned_runtime: Option, @@ -40,6 +53,9 @@ pub struct AgentBackend { remote_host: Option, retired_after_update: Arc, subscription_sequence: AtomicU64, + /// Whether the remote host's validated peer information advertises + /// input-only tool summaries; failed discovery is not remembered. + tool_summary_start: std::sync::OnceLock, } impl Drop for AgentBackend { @@ -254,6 +270,7 @@ operations! { list_slash_commands(user_id: (&str), working_dir: (Option)) -> Vec; resolve_slash_command(user_id: (&str), working_dir: (Option), command: (String), args: (String)) -> Option; summarize_tool_call(user_id: (&str), session_id: (&str), tool_name: (String), input: (Option), output_text: (String)) -> Option; + summarize_tool_call_start(user_id: (&str), session_id: (&str), tool_name: (String), input: (Option)) -> Option; summarize_thinking(user_id: (&str), session_id: (&str), thinking_text: (String)) -> Option; ask_side_question(user_id: (&str), session_id: (&str), request_id: (String), prior: (Vec), question: (String)) -> (); context_usage(user_id: (&str), session_id: (&str), model: (Option<&str>)) -> Option<(i64, i64)>; @@ -282,6 +299,7 @@ impl AgentBackend { remote_host: None, retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), + tool_summary_start: std::sync::OnceLock::new(), }) } @@ -304,6 +322,7 @@ impl AgentBackend { remote_host: None, retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), + tool_summary_start: std::sync::OnceLock::new(), } } @@ -320,6 +339,7 @@ impl AgentBackend { remote_host: None, retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), + tool_summary_start: std::sync::OnceLock::new(), }) } @@ -352,6 +372,7 @@ impl AgentBackend { remote_host: Some(peer), retired_after_update: self.retired_after_update.clone(), subscription_sequence: AtomicU64::new(0), + tool_summary_start: std::sync::OnceLock::new(), })) } @@ -377,6 +398,62 @@ impl AgentBackend { pub fn remote_host(&self) -> Option<&str> { self.remote_host.as_deref() } + + /// Summarize a tool call from its input as it starts. A remote host + /// must advertise the optional capability; otherwise the caller keeps + /// the output-based `summarize_tool_call` once the call completes. + pub async fn summarize_starting_tool_call( + &self, + user_id: &str, + session_id: &str, + tool_name: String, + input: Option, + ) -> Result { + if !self.host_summarizes_tool_calls_at_start().await { + return Ok(ToolStartSummary::NeedsOutput); + } + self.summarize_tool_call_start(user_id, session_id, tool_name, input) + .await + .map(ToolStartSummary::Label) + } + + async fn host_summarizes_tool_calls_at_start(&self) -> bool { + // The local host always runs this build. + if self.remote_host.is_none() { + return true; + } + if let Some(&known) = self.tool_summary_start.get() { + return known; + } + // Read the host's optional capabilities over this task connection. + // The read changes no pairing or update state. Only a validated + // answer is cached, for this connection; a probe that fails or + // times out leaves this call on the output-based summary and the + // next call asks again. + let probe = tokio::time::timeout( + TOOL_SUMMARY_PROBE_TIMEOUT, + self.call::("peer_info", serde_json::json!({})), + ) + .await; + match probe { + Ok(Ok(info)) if info.validate().is_ok() => *self + .tool_summary_start + .get_or_init(|| info.supports_tool_summary_start()), + Ok(Ok(_)) => { + log::debug!("Remote capabilities were invalid; summarizing from output"); + false + } + Ok(Err(_)) => { + log::debug!("Remote capability probe failed; summarizing from output"); + false + } + Err(_) => { + log::debug!("Remote capability probe timed out; summarizing from output"); + false + } + } + } + pub async fn remote_setting( &self, method: &str, @@ -732,6 +809,61 @@ mod tests { BackendClient::InProcess(Box::new(backend)) } + #[test] + fn legacy_tool_summary_call_keeps_its_wire_shape() { + let fixtures: serde_json::Value = + serde_json::from_str(include_str!("remote/tool_summary_compat.json")).unwrap(); + let shipped = &fixtures["legacy_summarize_command"]; + // Old client → new host: the shipped five-argument command still + // decodes and reaches the output-based summary. + let command: crate::remote::wire::Command = + serde_json::from_value(shipped.clone()).unwrap(); + assert_eq!( + command.method, + crate::remote::wire::Method::SummarizeToolCall + ); + // New client → any host: a finished call on an older host sends + // exactly the shipped arguments. + assert_eq!( + serde_json::to_value(( + "account", + "task", + "Terminal".to_string(), + Some(serde_json::json!({"command": "ls"})), + "main.rs".to_string(), + )) + .unwrap(), + shipped["params"] + ); + let backend = fixture(); + let runtime = backend.runtime_handle(); + runtime.block_on(async { + let legacy = backend + .call::>("summarize_tool_call", command.params.clone()) + .await; + assert_ne!(legacy, Err("Invalid host request parameters".into())); + // The input-only call is a separate operation with its own shape. + let start = backend + .call::>("summarize_tool_call_start", command.params) + .await; + assert_eq!(start, Err("Invalid host request parameters".into())); + }); + } + + #[test] + fn failed_tool_summary_capability_probe_is_not_remembered() { + let mut backend = + AgentBackend::for_test("http://127.0.0.1:1".into(), String::new()).unwrap(); + // The local host is always this build. + let runtime = backend.runtime_handle(); + assert!(runtime.block_on(backend.host_summarizes_tool_calls_at_start())); + // A remote host whose capabilities cannot be read keeps this call on + // the output-based summary, and the next call asks again. + backend.remote_host = Some("peer".into()); + assert!(!runtime.block_on(backend.host_summarizes_tool_calls_at_start())); + assert!(backend.tool_summary_start.get().is_none()); + } + #[test] fn retired_frontend_rejects_rpc_send_and_native_permissions_but_can_run_cleanup() { struct MustNotSerialize; @@ -870,6 +1002,7 @@ mod tests { remote_host: None, retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), + tool_summary_start: std::sync::OnceLock::new(), } } diff --git a/apps/maple-agent/app/src/backend/local.rs b/apps/maple-agent/app/src/backend/local.rs index 422053b52..e789f0aff 100644 --- a/apps/maple-agent/app/src/backend/local.rs +++ b/apps/maple-agent/app/src/backend/local.rs @@ -2816,6 +2816,22 @@ impl LocalBackend { .await } + /// One-line summary of a tool call from its name and input, asked as + /// the call starts. + pub async fn summarize_tool_call_start( + &self, + user_id: &str, + session_id: &str, + tool_name: String, + input: Option, + ) -> Result, String> { + self.service + .handle_for_user(user_id) + .await? + .summarize_tool_call_start(session_id, &tool_name, input.as_ref()) + .await + } + /// One-line summary of a finished thinking block from the summary model. pub async fn summarize_thinking( &self, diff --git a/apps/maple-agent/app/src/remote/tool_summary_compat.json b/apps/maple-agent/app/src/remote/tool_summary_compat.json new file mode 100644 index 000000000..b132bfa10 --- /dev/null +++ b/apps/maple-agent/app/src/remote/tool_summary_compat.json @@ -0,0 +1,32 @@ +{ + "legacy_summarize_command": { + "id": 7, + "method": "summarize_tool_call", + "send_id": null, + "params": [ + "account", + "task", + "Terminal", + { + "command": "ls" + }, + "main.rs" + ] + }, + "peer_info_without_tool_summary_start": { + "protocol": { + "version": "7.1.0", + "capabilities": [ + "peer_info_v1", + "remote_update_install_v1", + "remote_update_refresh_v1", + "project_folders_v1" + ] + }, + "running": { + "version": "0.0.9", + "source_sha": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "platform": "darwin-aarch64" + } + } +} diff --git a/apps/maple-agent/app/src/remote/update.rs b/apps/maple-agent/app/src/remote/update.rs index 5d920f383..a32cb82cc 100644 --- a/apps/maple-agent/app/src/remote/update.rs +++ b/apps/maple-agent/app/src/remote/update.rs @@ -11,6 +11,8 @@ pub const PEER_INFO_CAPABILITY: &str = "peer_info_v1"; pub const INSTALL_CAPABILITY: &str = "remote_update_install_v1"; pub const REFRESH_CAPABILITY: &str = "remote_update_refresh_v1"; pub const PROJECT_FOLDERS_CAPABILITY: &str = "project_folders_v1"; +/// The host summarizes a tool call from its input as the call starts. +pub const TOOL_SUMMARY_START_CAPABILITY: &str = "tool_summary_start_v1"; #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ProtocolInfo { @@ -28,6 +30,7 @@ impl ProtocolInfo { INSTALL_CAPABILITY.into(), REFRESH_CAPABILITY.into(), PROJECT_FOLDERS_CAPABILITY.into(), + TOOL_SUMMARY_START_CAPABILITY.into(), ], } } @@ -240,6 +243,10 @@ impl PeerInfo { self.protocol.supports(PROJECT_FOLDERS_CAPABILITY) } + pub fn supports_tool_summary_start(&self) -> bool { + self.protocol.supports(TOOL_SUMMARY_START_CAPABILITY) + } + pub(crate) fn validate(&self) -> Result<(), String> { if !text(&self.protocol.version, 64) || Version::parse(&self.protocol.version).is_err() @@ -936,6 +943,32 @@ mod tests { ); } + #[test] + fn input_only_tool_summaries_need_the_capability_and_current_major() { + // Agent 0.0.9 (management 7.1.0) answers peer info without the + // capability; a viewer keeps the output-based summaries with it. + let fixtures: serde_json::Value = + serde_json::from_str(include_str!("tool_summary_compat.json")).unwrap(); + let without: PeerInfo = + serde_json::from_value(fixtures["peer_info_without_tool_summary_start"].clone()) + .unwrap(); + without.validate().unwrap(); + assert!(without.supports_remote_updates()); + assert!(!without.supports_tool_summary_start()); + let mut info = PeerInfo { + protocol: ProtocolInfo::current(), + ..without + }; + for version in ["7.0.0", "7.1.0", "7.2.0"] { + info.protocol.version = version.into(); + assert!(info.supports_tool_summary_start(), "{version}"); + } + for version in ["6.2.0", "8.0.0", "invalid"] { + info.protocol.version = version.into(); + assert!(!info.supports_tool_summary_start(), "{version}"); + } + } + #[test] fn running_release_matching_honors_source_and_known_build() { let release = target(); diff --git a/apps/maple-agent/app/src/remote/wire.rs b/apps/maple-agent/app/src/remote/wire.rs index 760ce6750..710bce588 100644 --- a/apps/maple-agent/app/src/remote/wire.rs +++ b/apps/maple-agent/app/src/remote/wire.rs @@ -168,6 +168,7 @@ pub enum Method { ListSlashCommands, ResolveSlashCommand, SummarizeToolCall, + SummarizeToolCallStart, SummarizeThinking, AskSideQuestion, ContextUsage, diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 0aa45dcb5..93e7d63bc 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -728,12 +728,22 @@ pub struct ChatScreen { slash_selected: Option, /// Set when a pending question should steal focus at the next render. question_focus_pending: bool, - /// One-line model summaries per completed tool item id. + /// One-line model summaries per tool item id. tool_summaries: HashMap, /// Item ids with a summary request sent; never asked twice. summary_requests: HashSet, - /// Summary requests in flight; caps how many ride at once. - pending_summaries: usize, + /// Item ids whose summary landed before their row was durable; each is + /// stored when its row gains a persisted identity. + unsaved_summaries: HashSet, + /// Summary requests in flight by token, each with its row's current + /// key; caps how many ride at once. + summary_inflight: HashMap, + next_summary_token: u64, + /// Tool rows the host could not summarize from their input; each is + /// summarized from its output once it completes. + output_summaries: HashSet, + #[cfg(test)] + summary_probe: Option, /// Item ids waiting for a free summary slot, oldest first. summary_queue: std::collections::VecDeque, /// Bumped on session switch; results from an older generation are @@ -1408,7 +1418,12 @@ impl ChatScreen { question_focus_pending: false, tool_summaries: HashMap::new(), summary_requests: HashSet::new(), - pending_summaries: 0, + unsaved_summaries: HashSet::new(), + summary_inflight: HashMap::new(), + next_summary_token: 0, + output_summaries: HashSet::new(), + #[cfg(test)] + summary_probe: None, summary_queue: std::collections::VecDeque::new(), summary_generation: 0, summaries_enabled: settings.tool_summaries, @@ -3246,9 +3261,11 @@ impl ChatScreen { // Summaries in flight belong to the previous screen state; their // results are dropped and the queue restarts. self.summary_generation += 1; - self.pending_summaries = 0; + self.summary_inflight.clear(); self.summary_queue.clear(); self.summary_requests.clear(); + self.unsaved_summaries.clear(); + self.output_summaries.clear(); self.list_state.scroll_to_end(); self.awaiting_first_token = false; self.lightbox = None; @@ -5520,9 +5537,7 @@ impl ChatScreen { self.timeline_index.remove(&old); self.timeline[index].id = item.id.clone(); self.timeline_index.insert(item.id.clone(), (index, 0)); - if let Some(summary) = self.tool_summaries.remove(&old) { - self.tool_summaries.insert(item.id.clone(), summary); - } + self.rekey_summary(&old, &item.id); } if let Some((index, _)) = self.timeline_index.get(&item.id) && let (Some(old), Some(new)) = (&self.timeline[*index].persisted, &item.persisted) @@ -5671,7 +5686,7 @@ impl ChatScreen { self.awaiting_first_token = false; } // Cheap gates first so the common streaming chunk does no extra - // work; the summary check reads the stored item after the merge. + // work; the summary checks read the stored item after the merge. let completed = item.status.as_deref() == Some("completed"); let is_tool = matches!(item.item_type.as_str(), "tool" | "toolCall"); self.load_attachment_images_for(&item, cx); @@ -5695,10 +5710,15 @@ impl ChatScreen { { self.maybe_summarize_thinking(previous, cx); } + // A tool call is summarized from its input as soon as the call + // starts; the request-once gate makes later updates cheap. + if is_tool && self.summaries_enabled { + self.maybe_summarize_tool(index, cx); + } + if !self.unsaved_summaries.is_empty() { + self.save_summary(index, cx); + } if completed { - if is_tool && self.summaries_enabled { - self.maybe_summarize_tool(index, cx); - } // A finished item marks a turn boundary: the usage ledger has // a new row. self.refresh_context_usage(cx); diff --git a/apps/maple-agent/app/src/ui/chat/paging.rs b/apps/maple-agent/app/src/ui/chat/paging.rs index c0a5f91b6..c79ed58b4 100644 --- a/apps/maple-agent/app/src/ui/chat/paging.rs +++ b/apps/maple-agent/app/src/ui/chat/paging.rs @@ -187,6 +187,10 @@ impl ChatScreen { self.derived.clear(); self.tool_summaries .retain(|id, _| self.timeline_index.contains_key(id)); + self.unsaved_summaries + .retain(|id| self.timeline_index.contains_key(id)); + self.output_summaries + .retain(|id| self.timeline_index.contains_key(id)); self.pages .weights .retain(|id, _| self.timeline_index.contains_key(id)); diff --git a/apps/maple-agent/app/src/ui/chat/summaries.rs b/apps/maple-agent/app/src/ui/chat/summaries.rs index e4bf1c2a6..fd4845b51 100644 --- a/apps/maple-agent/app/src/ui/chat/summaries.rs +++ b/apps/maple-agent/app/src/ui/chat/summaries.rs @@ -10,7 +10,9 @@ use maple_agent::agent::AgentTimelineItem; use super::ChatScreen; use super::transcript::{has_tool_input, image_attachment_refs, tool_output_markdown}; -use crate::backend::{AgentBackend, ReadAttachmentRequest, StoreToolSummaryRequest}; +use crate::backend::{ + AgentBackend, ReadAttachmentRequest, StoreToolSummaryRequest, ToolStartSummary, +}; use crate::ui::chat::attachments::image_format_from_bytes; /// A thinking block shorter than this reads faster than its summary; @@ -25,31 +27,57 @@ const MAX_ATTACHMENT_READS: usize = 4; const ATTACHMENT_OVERDRAW_ROWS: usize = 5; const INITIAL_ATTACHMENT_ROWS: usize = 20; -/// The text a summary request describes: the output of a tool call, the -/// text of a thinking block. -fn summary_source(item: &AgentTimelineItem) -> Option { - match item.item_type.as_str() { - "thinking" | "reasoning" => item.text.clone().filter(|text| !text.trim().is_empty()), - _ => tool_output_markdown(item), - } +/// Summary requests that ride at once; the rest wait in the queue. +const SUMMARY_SLOTS: usize = 3; + +/// The text of a thinking block, which its summary describes. +fn thinking_text(item: &AgentTimelineItem) -> Option { + item.text.clone().filter(|text| !text.trim().is_empty()) +} + +/// What one summary request asks the summary model about. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum SummaryAsk { + Thinking(String), + /// A tool call from its input, as it starts. + ToolStart, + /// A finished tool call with its output, for a host that does not + /// summarize from input. + ToolOutput(String), +} + +/// Test seam: summary model requests and stores are recorded instead of +/// sent, so a test delivers each reply when it chooses. +#[cfg(test)] +#[derive(Default)] +pub(super) struct SummaryProbe { + /// Token, the row's key when asked, and what was asked. + pub(super) requests: Vec<(u64, String, SummaryAsk)>, + pub(super) saves: Vec, } impl ChatScreen { - /// Ask the summary model for a one-line summary of the completed tool - /// call at `index`. At most a few - /// requests ride at once; the rest wait in `summary_queue`. Each item - /// is only ever asked once per session visit. + /// Ask the summary model for a one-line summary of the tool call at + /// `index` as soon as its row carries the model's full input; the + /// summary describes the call, so it does not wait for the output. A + /// call the host could not summarize from its input is asked again once + /// it completes, with its output. Each item is only ever asked once per + /// session visit. pub(super) fn maybe_summarize_tool(&mut self, index: usize, cx: &mut Context) { let Some(item) = self.timeline.get(index) else { return; }; if !matches!(item.item_type.as_str(), "tool" | "toolCall") - || item.status.as_deref() != Some("completed") || self.tool_summaries.contains_key(&item.id) || self.summary_requests.contains(&item.id) { return; } + // The call's completion asks again. + let from_output = self.output_summaries.contains(&item.id); + if from_output && item.status.as_deref() != Some("completed") { + return; + } let skip = if !self.summaries_enabled { Some("summaries are off in settings") } else if has_tool_input(item, "todos") { @@ -60,6 +88,8 @@ impl ChatScreen { Some("external agent activity") } else if item.input.as_ref().is_none_or(serde_json::Value::is_null) { Some("no input") + } else if from_output && tool_output_markdown(item).is_none() { + Some("no text output") } else { None }; @@ -67,17 +97,8 @@ impl ChatScreen { log::debug!("Skipping tool summary for {}: {reason}", item.id); return; } - let Some(output) = tool_output_markdown(item) else { - log::debug!("Skipping tool summary for {}: no text output", item.id); - return; - }; let item_id = item.id.clone(); - self.summary_requests.insert(item_id.clone()); - if self.pending_summaries >= 3 { - self.summary_queue.push_back(item_id); - return; - } - self.start_summary(item_id, output, cx); + self.request_summary(item_id, cx); } /// Ask the summary model for a one-line header of the thinking block at @@ -99,38 +120,33 @@ impl ChatScreen { if index + 1 == self.timeline.len() && self.is_run_active() { return; } - let Some(text) = summary_source(item) else { - return; - }; - if text.trim().len() < THINKING_SUMMARY_MIN_CHARS { + if thinking_text(item).is_none_or(|text| text.trim().len() < THINKING_SUMMARY_MIN_CHARS) { return; } let item_id = item.id.clone(); + self.request_summary(item_id, cx); + } + + fn request_summary(&mut self, item_id: String, cx: &mut Context) { self.summary_requests.insert(item_id.clone()); - if self.pending_summaries >= 3 { + if self.summary_inflight.len() >= SUMMARY_SLOTS { self.summary_queue.push_back(item_id); return; } - self.start_summary(item_id, text, cx); + self.start_summary(item_id, cx); } /// Start queued summaries while a slot is free. fn drain_summary_queue(&mut self, cx: &mut Context) { - while self.pending_summaries < 3 { + while self.summary_inflight.len() < SUMMARY_SLOTS { let Some(item_id) = self.summary_queue.pop_front() else { return; }; - let Some(&(index, _)) = self.timeline_index.get(&item_id) else { - continue; - }; - let Some(output) = self.timeline.get(index).and_then(summary_source) else { - continue; - }; - self.start_summary(item_id, output, cx); + self.start_summary(item_id, cx); } } - fn start_summary(&mut self, item_id: String, output: String, cx: &mut Context) { + fn start_summary(&mut self, item_id: String, cx: &mut Context) { let Some(session_id) = self.selected_session.clone() else { self.summary_requests.remove(&item_id); return; @@ -140,84 +156,189 @@ impl ChatScreen { return; }; let item = &self.timeline[index]; - let thinking = matches!(item.item_type.as_str(), "thinking" | "reasoning"); - let input = item.input.clone(); - if !thinking && input.is_none() { + let ask = if matches!(item.item_type.as_str(), "thinking" | "reasoning") { + thinking_text(item).map(SummaryAsk::Thinking) + } else if item.input.is_none() { + None + } else if !self.output_summaries.contains(&item_id) { + Some(SummaryAsk::ToolStart) + } else if item.status.as_deref() == Some("completed") { + tool_output_markdown(item).map(SummaryAsk::ToolOutput) + } else { + // The call's completion asks again. + None + }; + let Some(ask) = ask else { self.summary_requests.remove(&item_id); return; - } + }; + let input = item.input.clone(); let tool_name = item.title.clone().unwrap_or_else(|| item.item_type.clone()); - log::debug!("Requesting summary for {item_id} ({tool_name})"); + let from = match &ask { + SummaryAsk::Thinking(_) => "thinking", + SummaryAsk::ToolStart => "input", + SummaryAsk::ToolOutput(_) => "output", + }; + log::debug!("Requesting summary for {item_id} ({tool_name}) from {from}"); + // The token names the request; `summary_inflight` maps it to the + // row's current key, which a live row's alias may change. + let token = self.next_summary_token; + self.next_summary_token += 1; + self.summary_inflight.insert(token, item_id.clone()); + #[cfg(test)] + if let Some(probe) = &mut self.summary_probe { + probe.requests.push((token, item_id, ask)); + return; + } let backend = self.backend.clone(); let user_id = self.user_id.clone(); let generation = self.summary_generation; - self.pending_summaries += 1; - let persisted = item.persisted.clone(); self.call( async move { - let summary = if thinking { - backend - .summarize_thinking(&user_id, &session_id, output) - .await? - } else { - backend - .summarize_tool_call(&user_id, &session_id, tool_name, input, output) - .await? - }; - if let (Some(summary), Some(row)) = (&summary, persisted) { - // A legacy item id can repeat. Persist only through the - // exact durable occurrence; live-only summaries stay here. - if let Err(error) = backend - .save_tool_summary( - &user_id, - StoreToolSummaryRequest { - task: row.task_id, - rows_epoch: row.rows_epoch, - row_seq: row.row_seq, - summary: summary.clone(), - }, - ) + match ask { + SummaryAsk::Thinking(text) => backend + .summarize_thinking(&user_id, &session_id, text) .await - { - log::warn!("Cannot store tool summary: {error}"); + .map(ToolStartSummary::Label), + SummaryAsk::ToolStart => { + backend + .summarize_starting_tool_call(&user_id, &session_id, tool_name, input) + .await } + SummaryAsk::ToolOutput(output) => backend + .summarize_tool_call(&user_id, &session_id, tool_name, input, output) + .await + .map(ToolStartSummary::Label), } - Ok::<_, String>(summary) }, cx, - move |this, result, cx| { - // The screen moved to another session meanwhile; the slot - // count restarted and this label has nowhere to go. - if this.summary_generation != generation { - return; + move |this, result, cx| this.finish_summary(token, generation, result, cx), + ); + } + + /// One summary request answered. It lands on the row its token now + /// names, even if that row became durable meanwhile. + pub(super) fn finish_summary( + &mut self, + token: u64, + generation: u64, + result: Result, + cx: &mut Context, + ) { + // The screen moved to another session meanwhile; the slots + // restarted and this label has nowhere to go. + if self.summary_generation != generation { + return; + } + let Some(item_id) = self.summary_inflight.remove(&token) else { + return; + }; + match result { + Ok(ToolStartSummary::Label(Some(summary))) => { + self.tool_summaries + .insert(item_id.clone(), SharedString::from(summary)); + if let Some(&(index, _)) = self.timeline_index.get(&item_id) { + self.remeasure_item(index); + // A call summarized as it starts may not have a + // durable row yet; it is stored once it does. + self.unsaved_summaries.insert(item_id); + self.save_summary(index, cx); } - this.pending_summaries = this.pending_summaries.saturating_sub(1); - match result { - Ok(Some(summary)) => { - if let Some(&(index, _)) = this.timeline_index.get(&item_id) { - this.remeasure_item(index); - } - this.tool_summaries - .insert(item_id, SharedString::from(summary)); - cx.notify(); - } - Ok(None) => { - log::debug!("Tool summary for {item_id} came back empty"); - } - Err(error) => { - // A failed request must not pin the raw title for - // the rest of the visit: forget it so the next - // reload or session switch asks again. - log::warn!("Tool summary for {item_id} failed: {error}"); - this.summary_requests.remove(&item_id); - } + cx.notify(); + } + Ok(ToolStartSummary::Label(None)) => { + log::debug!("Tool summary for {item_id} came back empty"); + } + Ok(ToolStartSummary::NeedsOutput) => { + log::debug!("Summary for {item_id} waits for its output"); + // The host does not summarize from input, or its support is + // unknown: this call is summarized from its output, now if it + // already finished, else on completion. The next call checks + // support again. + self.output_summaries.insert(item_id.clone()); + self.summary_requests.remove(&item_id); + if let Some(&(index, _)) = self.timeline_index.get(&item_id) { + self.maybe_summarize_tool(index, cx); + } + } + Err(error) => { + // A failed request must not pin the raw title for the rest + // of the visit: forget it so the next update, reload or + // session switch asks again. + log::warn!("Tool summary for {item_id} failed: {error}"); + self.summary_requests.remove(&item_id); + } + } + self.drain_summary_queue(cx); + } + + /// A row's key changed from `old` to `new` (its live row became + /// durable). Its summary state moves with it, so a queued or in-flight + /// request lands on the row as it is now and the new key never asks + /// again. + pub(super) fn rekey_summary(&mut self, old: &str, new: &str) { + if let Some(summary) = self.tool_summaries.remove(old) { + self.tool_summaries.insert(new.to_string(), summary); + } + for keys in [ + &mut self.summary_requests, + &mut self.unsaved_summaries, + &mut self.output_summaries, + ] { + if keys.remove(old) { + keys.insert(new.to_string()); + } + } + for key in self + .summary_queue + .iter_mut() + .chain(self.summary_inflight.values_mut()) + { + if key == old { + *key = new.to_string(); + } + } + } + + /// Store the summary of the row at `index` once the row is durable. A + /// legacy item id can repeat, so it is stored only through the exact + /// durable occurrence; until then it stays on screen only. + pub(super) fn save_summary(&mut self, index: usize, cx: &mut Context) { + let Some(item) = self.timeline.get(index) else { + return; + }; + let (Some(row), Some(summary)) = (&item.persisted, self.tool_summaries.get(&item.id)) + else { + return; + }; + if !self.unsaved_summaries.remove(&item.id) { + return; + } + let request = StoreToolSummaryRequest { + task: row.task_id.clone(), + rows_epoch: row.rows_epoch, + row_seq: row.row_seq, + summary: summary.to_string(), + }; + #[cfg(test)] + if let Some(probe) = &mut self.summary_probe { + probe.saves.push(request); + return; + } + let backend = self.backend.clone(); + let user_id = self.user_id.clone(); + self.call( + async move { backend.save_tool_summary(&user_id, request).await }, + cx, + |_, result, _| { + if let Err(error) = result { + log::warn!("Cannot store tool summary: {error}"); } - this.drain_summary_queue(cx); }, ); } - /// Request summaries for the completed tool calls and finished + /// Request summaries for the tool calls and finished /// thinking blocks of the loaded timeline (session switch or reload). pub(super) fn summarize_loaded_tools(&mut self, cx: &mut Context) { if self.selected_session.is_none() { diff --git a/apps/maple-agent/app/src/ui/chat/tests.rs b/apps/maple-agent/app/src/ui/chat/tests.rs index 83976fe18..823b3560a 100644 --- a/apps/maple-agent/app/src/ui/chat/tests.rs +++ b/apps/maple-agent/app/src/ui/chat/tests.rs @@ -3,6 +3,7 @@ mod state_tests { use std::sync::Arc; + use crate::backend::ToolStartSummary; use crate::ui::chat::attachments::MAX_DRAFT_ATTACHMENTS; use crate::ui::chat::cache::{INLINE_PARSE_LIMIT, MAX_DIFF_LINES, ORDINAL_SPACING}; use crate::ui::chat::composer::SideThreadTurn; @@ -10,7 +11,7 @@ mod state_tests { use crate::ui::chat::sidebar::{RenameTarget, SidebarEvent, TaskMove}; use crate::ui::chat::transcript::{diff_lines_for, tool_label_title}; use crate::ui::chat::*; - use gpui::TestAppContext; + use gpui::{Context, TestAppContext}; use maple_agent::agent::{ AgentMcpServer, AgentMcpTransport, AgentSessionIntegrationKind, AgentTaskState, RecentProjectRoot, @@ -2414,39 +2415,228 @@ mod state_tests { let screen = screen(cx); screen.update(cx, |this, cx| { this.summaries_enabled = true; - // Running tools are not summarized. - let index = this - .apply_timeline_item("s1", long_tool("tool-1", "running")) - .unwrap(); - this.maybe_summarize_tool(index, cx); + // A call still streaming its arguments has no input yet. + let mut started = item("tool-1", "tool", None); + started.status = Some("running".to_string()); + this.apply_incoming_item("s1", started, cx); assert!(this.summary_requests.is_empty()); - // Completed tools with long output are queued once. - let index = this - .apply_timeline_item("s1", long_tool("tool-1", "completed")) - .unwrap(); - this.maybe_summarize_tool(index, cx); + // Once the input is complete the call is summarized while it + // runs, without waiting for its output. + let mut running = long_tool("tool-1", "running"); + running.output = None; + this.apply_incoming_item("s1", running, cx); assert!(this.summary_requests.contains("tool-1")); - assert_eq!(this.pending_summaries, 1); - this.maybe_summarize_tool(index, cx); - assert_eq!(this.pending_summaries, 1); - // Short outputs queue too; only empty output is skipped. - let mut short = item("tool-2", "tool", None); - short.status = Some("completed".to_string()); - short.input = Some(serde_json::json!({"q": 1})); - short.output = Some(serde_json::json!({"stdout": "ok"})); - let index = this.apply_timeline_item("s1", short).unwrap(); + assert_eq!(this.summary_inflight.len(), 1); + // Its completion does not ask again. + this.apply_incoming_item("s1", long_tool("tool-1", "completed"), cx); + assert_eq!(this.summary_inflight.len(), 1); + // A call with no output at all is still summarized. + let mut silent = item("tool-2", "tool", None); + silent.status = Some("completed".to_string()); + silent.input = Some(serde_json::json!({"q": 1})); + silent.output = Some(serde_json::json!({"stdout": " "})); + let index = this.apply_timeline_item("s1", silent).unwrap(); this.maybe_summarize_tool(index, cx); assert!(this.summary_requests.contains("tool-2")); - let mut empty = item("tool-3", "tool", None); - empty.status = Some("completed".to_string()); - empty.input = Some(serde_json::json!({"q": 1})); - empty.output = Some(serde_json::json!({"stdout": " "})); - let index = this.apply_timeline_item("s1", empty).unwrap(); - this.maybe_summarize_tool(index, cx); + // Off in settings: nothing is asked. + this.summaries_enabled = false; + this.apply_incoming_item("s1", long_tool("tool-3", "running"), cx); assert!(!this.summary_requests.contains("tool-3")); }); } + /// A tool row as the live run streams it: occurrence `n` of run-1. + fn live_tool(n: u64, status: &str) -> AgentTimelineItem { + let mut tool = long_tool(&format!("tool-{n}"), status); + if status == "running" { + tool.output = None; + } + tool.live = Some(maple_agent::agent::AgentLiveRowIdentity { + run_id: "run-1".to_string(), + occurrence: n, + }); + tool + } + + /// The same row once committed, as durable row `row_seq` of task s1. + fn durable_tool(n: u64, row_seq: i64) -> AgentTimelineItem { + let mut tool = live_tool(n, "completed"); + tool.persisted = Some(maple_agent::agent::AgentPersistedRowIdentity { + task_id: "s1".to_string(), + rows_epoch: 1, + row_seq, + update_seq: 1, + }); + tool + } + + fn probe_summaries(this: &mut ChatScreen) { + this.summaries_enabled = true; + this.summary_probe = Some(Default::default()); + } + + fn probe(this: &ChatScreen) -> &summaries::SummaryProbe { + this.summary_probe.as_ref().unwrap() + } + + fn answer(this: &mut ChatScreen, token: u64, label: &str, cx: &mut Context) { + let generation = this.summary_generation; + this.finish_summary( + token, + generation, + Ok(ToolStartSummary::Label(Some(label.to_string()))), + cx, + ); + } + + /// No summary state is left under a live key once its row is durable. + fn assert_no_live_keys(this: &ChatScreen) { + assert!( + this.tool_summaries + .keys() + .all(|key| !key.starts_with("live:")) + ); + assert!( + this.summary_requests + .iter() + .all(|key| !key.starts_with("live:")) + ); + assert!(this.unsaved_summaries.is_empty()); + assert!(this.summary_queue.is_empty()); + assert!(this.summary_inflight.is_empty()); + } + + fn assert_saved_once(this: &ChatScreen, row_seq: i64, label: &str) { + let saves = &probe(this).saves; + assert_eq!(saves.len(), 1); + assert_eq!( + ( + saves[0].task.as_str(), + saves[0].rows_epoch, + saves[0].row_seq + ), + ("s1", 1, row_seq) + ); + assert_eq!(saves[0].summary, label); + } + + #[gpui::test] + fn test_tool_summary_persisted_before_its_reply_lands_on_the_durable_row( + cx: &mut TestAppContext, + ) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + probe_summaries(this); + this.apply_incoming_item("s1", live_tool(1, "running"), cx); + let (token, key, ask) = probe(this).requests[0].clone(); + assert_eq!( + (key.as_str(), ask), + ("live:run-1:1", summaries::SummaryAsk::ToolStart) + ); + // The row commits while the model is still answering. + this.apply_incoming_item("s1", durable_tool(1, 3), cx); + assert_eq!(probe(this).requests.len(), 1); + answer(this, token, "Lists the files", cx); + assert_eq!(probe(this).requests.len(), 1); + assert_eq!( + this.tool_summaries.get("stored:s1:1:3").map(|s| s.as_ref()), + Some("Lists the files") + ); + assert_saved_once(this, 3, "Lists the files"); + assert_no_live_keys(this); + }); + } + + #[gpui::test] + fn test_tool_summary_reply_before_persistence_is_stored_once_durable(cx: &mut TestAppContext) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + probe_summaries(this); + this.apply_incoming_item("s1", live_tool(1, "running"), cx); + let token = probe(this).requests[0].0; + answer(this, token, "Lists the files", cx); + // Live-only so far: shown, not stored. + assert!(this.tool_summaries.contains_key("live:run-1:1")); + assert!(probe(this).saves.is_empty()); + this.apply_incoming_item("s1", durable_tool(1, 3), cx); + assert_eq!(probe(this).requests.len(), 1); + assert_eq!( + this.tool_summaries.get("stored:s1:1:3").map(|s| s.as_ref()), + Some("Lists the files") + ); + assert_saved_once(this, 3, "Lists the files"); + assert_no_live_keys(this); + }); + } + + #[gpui::test] + fn test_queued_tool_summary_follows_its_row_when_it_becomes_durable(cx: &mut TestAppContext) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + probe_summaries(this); + for n in 1..=4 { + this.apply_incoming_item("s1", live_tool(n, "running"), cx); + } + // Three ride; the fourth waits for a slot. + assert_eq!(probe(this).requests.len(), 3); + assert_eq!(this.summary_queue.len(), 1); + this.apply_incoming_item("s1", durable_tool(4, 9), cx); + assert_eq!(probe(this).requests.len(), 3); + for token in [0, 1, 2] { + answer(this, token, &format!("Call {token}"), cx); + } + // The freed slot asks for the row under its durable key. + assert_eq!(probe(this).requests.len(), 4); + let (token, key, _) = probe(this).requests[3].clone(); + assert_eq!(key, "stored:s1:1:9"); + answer(this, token, "Fourth call", cx); + assert_eq!(probe(this).requests.len(), 4); + assert_eq!( + this.tool_summaries.get("stored:s1:1:9").map(|s| s.as_ref()), + Some("Fourth call") + ); + assert!(!this.tool_summaries.contains_key("live:run-1:4")); + // Only the durable row is stored; the others are still live. + assert_saved_once(this, 9, "Fourth call"); + assert_eq!(this.unsaved_summaries.len(), 3); + }); + } + + #[gpui::test] + fn test_host_without_input_summaries_summarizes_that_call_from_its_output( + cx: &mut TestAppContext, + ) { + let screen = screen(cx); + screen.update(cx, |this, cx| { + probe_summaries(this); + this.apply_incoming_item("s1", live_tool(1, "running"), cx); + let generation = this.summary_generation; + this.finish_summary(0, generation, Ok(ToolStartSummary::NeedsOutput), cx); + // That call waits for its output; the next call checks support + // again instead of remembering the host as unsupported. + this.apply_incoming_item("s1", live_tool(2, "running"), cx); + assert_eq!(probe(this).requests.len(), 2); + assert_eq!(probe(this).requests[1].2, summaries::SummaryAsk::ToolStart); + // The waiting row commits, then completes: it is asked once, with + // its output, under its durable key. + this.apply_incoming_item("s1", durable_tool(1, 3), cx); + let (token, key, ask) = probe(this).requests[2].clone(); + assert_eq!(key, "stored:s1:1:3"); + assert!( + matches!(ask, summaries::SummaryAsk::ToolOutput(output) if output.contains("xxx")) + ); + answer(this, token, "Listed the files", cx); + this.apply_incoming_item("s1", durable_tool(1, 3), cx); + assert_eq!(probe(this).requests.len(), 3); + assert_saved_once(this, 3, "Listed the files"); + assert!( + this.output_summaries + .iter() + .all(|key| !key.starts_with("live:")) + ); + }); + } + #[gpui::test] fn test_thinking_summary_waits_for_a_following_item(cx: &mut TestAppContext) { cx.executor().allow_parking(); @@ -2485,14 +2675,14 @@ mod state_tests { this.maybe_summarize_tool(index, cx); } // Three ride at once; the rest wait instead of being dropped. - assert_eq!(this.pending_summaries, 3); + assert_eq!(this.summary_inflight.len(), 3); assert_eq!(this.summary_queue.len(), 2); assert_eq!(this.summary_requests.len(), 5); // A session switch restarts the slots and drops the queue. let before = this.summary_generation; this.set_active_session(summary("s2", "B"), Vec::new(), HashMap::new(), cx); assert_eq!(this.summary_generation, before + 1); - assert_eq!(this.pending_summaries, 0); + assert!(this.summary_inflight.is_empty()); assert!(this.summary_queue.is_empty()); assert!(this.summary_requests.is_empty()); }); diff --git a/apps/maple-agent/app/src/ui/chat/transcript.rs b/apps/maple-agent/app/src/ui/chat/transcript.rs index 29ab64f6a..392313a77 100644 --- a/apps/maple-agent/app/src/ui/chat/transcript.rs +++ b/apps/maple-agent/app/src/ui/chat/transcript.rs @@ -1119,8 +1119,11 @@ fn render_tool( ); let item_id = item.id.clone(); let chat_header = transcript.chat.clone(); - let summary = transcript.tool_summaries.get(&item.id).cloned(); - let has_summary = summary.is_some(); + // An external agent's row draws its own activity; a summary asked + // when its call started would hide what it did. + let summary = (!has_external_agent_activity(item)) + .then(|| transcript.tool_summaries.get(&item.id).cloned()) + .flatten(); // The model summary stands in for the title; until it lands, show just // the tool label ("Terminal"), not the raw `tool: args` title. let title = summary.clone().unwrap_or_else(|| { @@ -1211,8 +1214,8 @@ fn render_tool( } // A click anywhere on the card, payload included, toggles it. let mut payload = div().flex().flex_col().gap_1(); - // Expanded: the call arguments and, until a summary exists, the raw - // output. + // Expanded: the call arguments and the raw output. The summary is + // made from the input alone, so it never stands in for the output. if let Some(input) = &derived.input_line { payload = payload.child( div() @@ -1223,7 +1226,7 @@ fn render_tool( .child(input.clone()), ); } - if !has_summary && let Some(output) = &derived.output_text { + if let Some(output) = &derived.output_text { payload = payload.child( typography::chat_reading(div()) .mt_1() diff --git a/apps/maple-agent/crates/maple-agent/src/agent/side_models.rs b/apps/maple-agent/crates/maple-agent/src/agent/side_models.rs index 320f50811..f0ca118b4 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/side_models.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/side_models.rs @@ -84,6 +84,7 @@ const SUMMARY_MAX_TOKENS: u64 = 48; const SUMMARY_MAX_INPUT_CHARS: usize = 4000; const SUMMARY_MAX_CHARS: usize = 100; const TOOL_SUMMARY_SYSTEM_PROMPT: &str = "You summarize one tool call for a coding agent's activity feed. Reply with ONE short line of at most 12 words that says what the call did. No prefix, no quotes, no explanations."; +const TOOL_START_SUMMARY_SYSTEM_PROMPT: &str = "You label one tool call for a coding agent's activity feed from the tool's name and input, before its result is known. Say what the call is for and what it acts on: the program, file, path, query or text that matters. Ignore incidental settings such as timeouts, sleeps, delays, retries and output limits. Never state or imply a result, success or failure. Reply with ONE short line of at most 12 words that starts with a present-tense verb, for example: Installs nitro_cli with cargo. No prefix, no quotes, no explanations."; const THINKING_SUMMARY_SYSTEM_PROMPT: &str = "You summarize a coding agent's reasoning for its activity feed. Reply with ONE short line of at most 12 words that says what the agent thought about or decided. No prefix, no quotes, no explanations."; const SIDE_QUESTION_TIMEOUT: Duration = Duration::from_secs(120); @@ -470,9 +471,20 @@ async fn stream_side_question( } } +/// The bounded JSON of a tool call's input, or nothing. +fn tool_input_line(input: Option<&Value>) -> String { + match input { + Some(value) if !value.is_null() => { + bounded_input(&serde_json::to_string(value).unwrap_or_default()) + } + _ => String::new(), + } +} + impl AgentRuntimeHandle { /// A one-line summary of a finished tool call for the activity feed, or - /// `None` when the model gave nothing usable. + /// `None` when the model gave nothing usable. Clients older than + /// [`Self::summarize_tool_call_start`] still call this, remotely too. pub async fn summarize_tool_call( &self, session_id: &str, @@ -480,20 +492,29 @@ impl AgentRuntimeHandle { input: Option<&Value>, output_text: &str, ) -> Result, String> { - let input_line = match input { - Some(value) if !value.is_null() => { - bounded_input(&serde_json::to_string(value).unwrap_or_default()) - } - _ => String::new(), - }; let text = format!( - "Tool: {tool_name}\nInput: {input_line}\nOutput: {}", + "Tool: {tool_name}\nInput: {}\nOutput: {}", + tool_input_line(input), bounded_input(output_text) ); self.summarize(session_id, TOOL_SUMMARY_SYSTEM_PROMPT, text) .await } + /// A one-line summary of a tool call from its name and input alone, so + /// it can be asked as soon as the call starts, or `None` when the model + /// gave nothing usable. + pub async fn summarize_tool_call_start( + &self, + session_id: &str, + tool_name: &str, + input: Option<&Value>, + ) -> Result, String> { + let text = format!("Tool: {tool_name}\nInput: {}", tool_input_line(input)); + self.summarize(session_id, TOOL_START_SUMMARY_SYSTEM_PROMPT, text) + .await + } + /// A one-line summary of a finished thinking block, for the header of /// its row. pub async fn summarize_thinking( diff --git a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs index a73ad6445..344667d07 100644 --- a/apps/maple-agent/crates/maple-agent/src/agent/tests.rs +++ b/apps/maple-agent/crates/maple-agent/src/agent/tests.rs @@ -722,6 +722,7 @@ async fn a_new_task_gets_a_generated_title_unless_renamed_first() { async fn tool_calls_and_thinking_are_summarized_by_deepseek_without_thinking() { let harness = Harness::new().await; harness.side.push_text("\"Listed the files\"\nextra"); + harness.side.push_text("Lists the files"); harness.side.push_text("Decided to read main.rs first"); let task = harness.create_task().await; let summary = harness @@ -730,6 +731,12 @@ async fn tool_calls_and_thinking_are_summarized_by_deepseek_without_thinking() { .await .unwrap(); assert_eq!(summary.as_deref(), Some("Listed the files")); + let summary = harness + .handle + .summarize_tool_call_start(&task, "bash", Some(&json!({"command": "ls"}))) + .await + .unwrap(); + assert_eq!(summary.as_deref(), Some("Lists the files")); let summary = harness .handle .summarize_thinking(&task, "I should read main.rs before editing.") @@ -737,7 +744,7 @@ async fn tool_calls_and_thinking_are_summarized_by_deepseek_without_thinking() { .unwrap(); assert_eq!(summary.as_deref(), Some("Decided to read main.rs first")); let requests = harness.side.requests(); - assert_eq!(requests.len(), 2); + assert_eq!(requests.len(), 3); for request in &requests { let body = pi_ai::openai::build_request_body(&request.model, &request.context, &request.options) @@ -747,10 +754,20 @@ async fn tool_calls_and_thinking_are_summarized_by_deepseek_without_thinking() { assert_eq!(body["max_tokens"], 48); assert!(body.get("tools").is_none()); } + // Legacy callers keep the output-based request; a call summarized as it + // starts sends only its input. assert_eq!( last_user_text(&requests[0]), "Tool: bash\nInput: {\"command\":\"ls\"}\nOutput: main.rs" ); + assert_eq!( + last_user_text(&requests[1]), + "Tool: bash\nInput: {\"command\":\"ls\"}" + ); + assert_ne!( + pi_ai::transcript::current_system_prompt(&requests[0].context.messages), + pi_ai::transcript::current_system_prompt(&requests[1].context.messages) + ); assert_eq!(requests[0].options.max_tokens, Some(48)); assert!( harness.faux.requests().is_empty(), diff --git a/apps/maple-agent/docs/remote-access.md b/apps/maple-agent/docs/remote-access.md index 745e7c9c2..d0c841d68 100644 --- a/apps/maple-agent/docs/remote-access.md +++ b/apps/maple-agent/docs/remote-access.md @@ -233,6 +233,9 @@ reconnecting checks it again. Failed discovery remains retryable rather than being remembered as an unsupported host. Folder dispatch rechecks account and grant authority after discovery, including when support was cached. Update offers and installation readiness are always read afresh. +Input-only tool summaries require `tool_summary_start_v1`; without it, the +controller summarizes that call from its output once it finishes, as earlier +versions did, and the next call checks support again. Four pre-admission handshakes and eight paired control connections have capacity separate from local IPC. Each control connection bounds ordinary calls and keeps