feat: fast_complete summary of tool calls as they happen in the TUI and other ACP clients (#8393)

This commit is contained in:
Alex Hancock
2026-04-08 14:32:08 -04:00
committed by GitHub
parent 8b32f92371
commit 4fb37ee161
2 changed files with 176 additions and 68 deletions
+175 -26
View File
@@ -290,30 +290,48 @@ fn read_resource_link(link: ResourceLink) -> Option<String> {
}
fn format_tool_name(tool_name: &str) -> String {
let capitalize = |s: &str| {
s.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
};
if let Some((extension, tool)) = tool_name.split_once("__") {
let formatted_extension = extension.replace('_', " ");
let formatted_tool = tool.replace('_', " ");
format!(
"{}: {}",
capitalize(&formatted_extension),
capitalize(&formatted_tool)
extension.replace('_', " "),
tool.replace('_', " ")
)
} else {
let formatted = tool_name.replace('_', " ");
capitalize(&formatted)
tool_name.replace('_', " ")
}
}
/// Build a short fallback title from the tool name and arguments by extracting
/// the most useful value (file path, command, query, url, etc.).
fn summarize_tool_call(tool_name: &str, arguments: Option<&serde_json::Value>) -> String {
let base = format_tool_name(tool_name);
let detail = arguments.and_then(|args| {
let obj = args.as_object()?;
let keys = [
"path", "file", "command", "query", "url", "uri", "name", "pattern", "source",
];
for key in &keys {
if let Some(v) = obj.get(*key) {
let s = match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
if !s.is_empty() {
let first_line = s.lines().next().unwrap_or(&s);
if first_line.len() > 60 {
return Some(format!("{}", goose::utils::safe_truncate(first_line, 57)));
}
return Some(first_line.to_string());
}
}
}
None
});
match detail {
Some(d) => format!("{base} · {d}"),
None => base,
}
}
@@ -718,17 +736,113 @@ impl GooseAcpAgent {
Err(_) => "error".to_string(),
};
let args_value = tool_request
.tool_call
.as_ref()
.ok()
.and_then(|tc| tc.arguments.as_ref())
.map(|a| serde_json::Value::Object(a.clone()));
let fallback_title = summarize_tool_call(&tool_name, args_value.as_ref());
cx.send_notification(SessionNotification::new(
session_id.clone(),
SessionUpdate::ToolCall(
ToolCall::new(
ToolCallId::new(tool_request.id.clone()),
format_tool_name(&tool_name),
fallback_title.clone(),
)
.status(ToolCallStatus::Pending),
),
))?;
if let Ok(tool_call) = &tool_request.tool_call {
let agent = session.agent.clone();
let sid = session_id.clone();
let request_id = tool_request.id.clone();
let cx = cx.clone();
let name = tool_call.name.to_string();
let args_json = tool_call
.arguments
.as_ref()
.map(|a| {
let s = serde_json::to_string(a).unwrap_or_default();
if s.len() > 300 {
format!("{}", goose::utils::safe_truncate(&s, 300))
} else {
s
}
})
.unwrap_or_default();
tokio::spawn(async move {
let provider = match agent.provider().await {
Ok(p) => p,
Err(e) => {
warn!("tool call summary: failed to get provider: {e}");
let fields = ToolCallUpdateFields::new().title(fallback_title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
return;
}
};
// in these case, the title summarization request would
// be added to the conversation which we don't want
if provider.manages_own_context() {
return;
}
let system = "Summarize this tool call in a short lowercase phrase (3-8 words). \
No punctuation. No quotes. Examples: reading project configuration, \
checking network connectivity, listing files in src directory";
let user_text = format!("Tool: {name}\nArguments: {args_json}");
let message = Message::user().with_text(&user_text);
match provider
.complete_fast(&sid.0, system, &[message], &[])
.await
{
Ok((response, _)) => {
let summary: String = response
.content
.iter()
.filter_map(|c| c.as_text())
.collect::<String>()
.trim()
.to_string();
let title = if summary.is_empty() {
fallback_title
} else {
summary
};
let fields = ToolCallUpdateFields::new().title(title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
}
Err(e) => {
warn!("tool call summary: fast_complete failed: {e}");
let fields = ToolCallUpdateFields::new().title(fallback_title);
let _ = cx.send_notification(SessionNotification::new(
sid,
SessionUpdate::ToolCallUpdate(ToolCallUpdate::new(
ToolCallId::new(request_id),
fields,
)),
));
}
}
});
}
Ok(())
}
@@ -2231,19 +2345,54 @@ print(\"hello, world\")
#[test]
fn test_format_tool_name_with_extension() {
assert_eq!(format_tool_name("developer__edit"), "Developer: Edit");
assert_eq!(format_tool_name("developer__edit"), "developer: edit");
assert_eq!(
format_tool_name("platform__manage_extensions"),
"Platform: Manage Extensions"
"platform: manage extensions"
);
assert_eq!(format_tool_name("todo__write"), "Todo: Write");
assert_eq!(format_tool_name("todo__write"), "todo: write");
}
#[test]
fn test_format_tool_name_without_extension() {
assert_eq!(format_tool_name("simple_tool"), "Simple Tool");
assert_eq!(format_tool_name("another_name"), "Another Name");
assert_eq!(format_tool_name("single"), "Single");
assert_eq!(format_tool_name("simple_tool"), "simple tool");
assert_eq!(format_tool_name("another_name"), "another name");
assert_eq!(format_tool_name("single"), "single");
}
#[test]
fn test_summarize_tool_call_no_args() {
assert_eq!(
summarize_tool_call("developer__shell", None),
"developer: shell"
);
}
#[test]
fn test_summarize_tool_call_with_path() {
let args = serde_json::json!({"path": "/src/main.rs", "content": "fn main() {}"});
assert_eq!(
summarize_tool_call("developer__edit", Some(&args)),
"developer: edit · /src/main.rs"
);
}
#[test]
fn test_summarize_tool_call_with_command() {
let args = serde_json::json!({"command": "cargo build"});
assert_eq!(
summarize_tool_call("developer__shell", Some(&args)),
"developer: shell · cargo build"
);
}
#[test]
fn test_summarize_tool_call_long_value_truncated() {
let long_path = "a".repeat(80);
let args = serde_json::json!({"path": long_path});
let result = summarize_tool_call("developer__read_file", Some(&args));
assert!(result.ends_with('…'));
assert!(result.len() < 90);
}
#[test_case(
+1 -42
View File
@@ -81,42 +81,6 @@ function extractTextLines(content: ToolCallContent[], maxWidth: number): string[
return lines;
}
function summarizeContent(info: ToolCallInfo, maxWidth: number): string {
const parts: string[] = [];
if (info.locations && info.locations.length > 0) {
for (const loc of info.locations) {
parts.push(loc.path + (loc.line ? `:${loc.line}` : ""));
}
}
if (info.content && info.content.length > 0) {
const textLines = extractTextLines(info.content, maxWidth);
if (textLines.length > 0) {
const first = textLines[0]!.trim();
if (first.length > 60) {
parts.push(first.slice(0, 57) + "…");
} else if (first) {
parts.push(first);
}
}
}
if (parts.length === 0 && info.rawOutput !== undefined && info.rawOutput !== null) {
const raw = String(
typeof info.rawOutput === "string" ? info.rawOutput : JSON.stringify(info.rawOutput),
);
const firstLine = raw.split("\n")[0] ?? "";
if (firstLine.length > 60) {
parts.push(firstLine.slice(0, 57) + "…");
} else if (firstLine) {
parts.push(firstLine);
}
}
return truncateLine(parts.join(" · "), maxWidth);
}
export function renderToolCallLines(
info: ToolCallInfo,
width: number,
@@ -171,12 +135,7 @@ export function renderToolCallLines(
</>
));
if (!expanded) {
const summary = summarizeContent(info, innerWidth);
if (summary) {
row(`${k}-s`, <Text wrap="truncate-end" color={TEXT_DIM}>{summary}</Text>);
}
} else {
if (expanded) {
if (info.locations) {
for (let i = 0; i < info.locations.length; i++) {
const loc = info.locations[i]!;