feat (acp): Gate tool-call label enrichment on ACP client capability (#10644)

This commit is contained in:
Lifei Zhou
2026-07-23 15:04:30 +10:00
committed by GitHub
parent d5785a3671
commit 45815e1d31
5 changed files with 166 additions and 111 deletions
+30 -83
View File
@@ -201,7 +201,7 @@ pub struct GooseAcpAgent {
client_supports_acp_elicitation: OnceCell<bool>,
client_supports_goose_custom_notifications: OnceCell<bool>,
client_supports_recipe_param_requests: OnceCell<bool>,
client_supports_tool_call_label_enrichment: OnceCell<bool>,
client_requests_tool_call_label_enrichment: OnceCell<bool>,
use_login_shell_path: OnceCell<bool>,
client_cx: OnceCell<ConnectionTo<Client>>,
config_dir: std::path::PathBuf,
@@ -575,6 +575,13 @@ impl GooseAcpAgent {
.unwrap_or(false)
}
fn requests_tool_call_label_enrichment(&self) -> bool {
self.client_requests_tool_call_label_enrichment
.get()
.copied()
.unwrap_or(false)
}
fn supports_acp_elicitation(&self) -> bool {
self.client_supports_acp_elicitation
.get()
@@ -617,7 +624,7 @@ impl GooseAcpAgent {
client_supports_acp_elicitation: OnceCell::new(),
client_supports_goose_custom_notifications: OnceCell::new(),
client_supports_recipe_param_requests: OnceCell::new(),
client_supports_tool_call_label_enrichment: OnceCell::new(),
client_requests_tool_call_label_enrichment: OnceCell::new(),
use_login_shell_path: OnceCell::new(),
client_cx: OnceCell::new(),
config_dir: options.config_dir,
@@ -1125,6 +1132,10 @@ impl GooseAcpAgent {
session_id: &SessionId,
cx: &ConnectionTo<Client>,
) {
if !self.requests_tool_call_label_enrichment() {
return;
}
let tool_call_notifier = ToolCallNotifier::new(cx, session_id);
spawn_chain_summary_enrichment(
agent,
@@ -1144,14 +1155,13 @@ impl GooseAcpAgent {
agent: &Arc<Agent>,
cx: &ConnectionTo<Client>,
) -> Result<(), agent_client_protocol::Error> {
let initial_tool_call = build_initial_tool_call(tool_request);
let client_requests_label_enrichment = self.requests_tool_call_label_enrichment();
let initial_tool_call =
build_initial_tool_call(tool_request, client_requests_label_enrichment);
let tool_call_notifier = ToolCallNotifier::new(cx, session_id);
tool_call_notifier.send_initial(initial_tool_call)?;
if Config::global()
.get_goose_disable_tool_call_summary()
.unwrap_or(false)
{
if !client_requests_label_enrichment {
return Ok(());
}
@@ -1282,14 +1292,6 @@ fn extract_client_supports_recipe_param_requests(
.unwrap_or(false)
}
fn extract_client_supports_tool_call_label_enrichment(
goose_client_capabilities: Option<&GooseClientCapabilities>,
) -> bool {
goose_client_capabilities
.and_then(|goose| goose.tool_call_label_enrichment)
.unwrap_or(false)
}
fn outcome_to_confirmation(outcome: &RequestPermissionOutcome) -> PermissionConfirmation {
PermissionConfirmation {
principal_type: PrincipalType::Tool,
@@ -1450,9 +1452,13 @@ impl GooseAcpAgent {
let _ = self.client_supports_recipe_param_requests.set(
extract_client_supports_recipe_param_requests(goose_client_capabilities.as_ref()),
);
let _ = self.client_supports_tool_call_label_enrichment.set(
extract_client_supports_tool_call_label_enrichment(goose_client_capabilities.as_ref()),
);
let client_requests_tool_call_label_enrichment = goose_client_capabilities
.as_ref()
.and_then(|goose| goose.tool_call_label_enrichment)
.unwrap_or(false);
let _ = self
.client_requests_tool_call_label_enrichment
.set(client_requests_tool_call_label_enrichment);
let _ = self
.client_supports_acp_elicitation
.set(elicitation::client_supports_form_elicitation(&args));
@@ -2345,15 +2351,12 @@ pub async fn run(builtins: Vec<String>) -> Result<()> {
#[cfg(test)]
mod tests {
use super::*;
use crate::acp::server::tool_calls::enrichment::tool_chain_summary;
use crate::conversation::message::ToolRequest;
use crate::session::session_manager::SessionType;
use agent_client_protocol::schema::v1::{
EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerSse, McpServerStdio,
PermissionOptionId, ResourceLink, SelectedPermissionOutcome,
};
use goose_providers::conversation::token_usage::Usage as TokenUsage;
use rmcp::model::CallToolRequestParams;
use std::io::Write;
use std::path::PathBuf;
use tempfile::NamedTempFile;
@@ -2449,62 +2452,6 @@ print(\"hello, world\")
assert_eq!(result, expected,)
}
#[test]
fn replay_attaches_chain_summary_meta_for_first_tool_request_with_persisted_summary() {
let tool_request = ToolRequest {
id: "req_first".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: Some(serde_json::json!({
crate::conversation::message::TOOL_META_CHAIN_SUMMARY_KEY: {
"summary": "applied dark mode polish",
"count": 3,
},
})),
};
let mut initial_tool_call = build_initial_tool_call(&tool_request);
let goose = initial_tool_call
.meta
.as_mut()
.and_then(|meta| meta.get_mut("goose"))
.and_then(serde_json::Value::as_object_mut)
.expect("valid initial tool call should contain goose metadata");
let chain_summary = tool_request
.generated_chain_summary()
.expect("chain summary should be present");
goose.extend([tool_chain_summary(&chain_summary)]);
assert_eq!(
goose.get("toolCall"),
Some(
&serde_json::json!({ "toolName": "developer__shell", "extensionName": "developer" })
),
"replay must preserve identity meta alongside the chain summary",
);
assert_eq!(
goose.get("toolChainSummary"),
Some(&serde_json::json!({ "summary": "applied dark mode polish", "count": 3 })),
"replay must attach toolChainSummary so the chain header renders on first paint",
);
}
#[test]
fn replay_does_not_attach_chain_summary_for_tool_requests_without_persisted_summary() {
let tool_request = ToolRequest {
id: "req_second".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: None,
};
let chain_summary = tool_request.generated_chain_summary();
assert!(
chain_summary.is_none(),
"non-first tool requests must not carry chain summaries",
);
}
#[test_case(
RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(PermissionOptionId::from("allow_once".to_string()))),
PermissionConfirmation { principal_type: PrincipalType::Tool, permission: Permission::AllowOnce };
@@ -2720,9 +2667,9 @@ print(\"hello, world\")
InitializeRequest::new(agent_client_protocol::schema::ProtocolVersion::LATEST);
let goose_client_capabilities =
extract_client_capabilities_meta(&request).and_then(|meta| meta.goose);
assert!(!extract_client_supports_tool_call_label_enrichment(
goose_client_capabilities.as_ref()
));
assert!(!goose_client_capabilities
.and_then(|goose| goose.tool_call_label_enrichment)
.unwrap_or(false));
let mut goose_meta = serde_json::Map::new();
goose_meta.insert(
@@ -2738,8 +2685,8 @@ print(\"hello, world\")
);
let goose_client_capabilities =
extract_client_capabilities_meta(&request).and_then(|meta| meta.goose);
assert!(extract_client_supports_tool_call_label_enrichment(
goose_client_capabilities.as_ref()
));
assert!(goose_client_capabilities
.and_then(|goose| goose.tool_call_label_enrichment)
.unwrap_or(false));
}
}
+100 -16
View File
@@ -3,6 +3,7 @@ use super::tool_calls::conversion::{
};
use super::tool_calls::enrichment::tool_chain_summary;
use super::*;
use agent_client_protocol::schema::v1::ToolCall;
fn replay_message_meta(message: &Message) -> Meta {
let mut meta = serde_json::Map::new();
@@ -69,10 +70,41 @@ fn send_replay_content_chunk(
cx.send_notification(SessionNotification::new(session_id.clone(), update))
}
fn build_replayed_tool_call(
tool_request: &ToolRequest,
client_requests_tool_call_label_enrichment: bool,
) -> ToolCall {
let mut tool_call =
build_initial_tool_call(tool_request, client_requests_tool_call_label_enrichment);
if !client_requests_tool_call_label_enrichment {
return tool_call;
}
let Some(chain_summary) = tool_request.generated_chain_summary() else {
return tool_call;
};
let goose_meta = tool_call
.meta
.get_or_insert_default()
.entry("goose".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !goose_meta.is_object() {
*goose_meta = serde_json::Value::Object(serde_json::Map::new());
}
goose_meta
.as_object_mut()
.expect("goose metadata was initialized as an object")
.extend([tool_chain_summary(&chain_summary)]);
tool_call
}
fn replay_conversation_to_client(
cx: &ConnectionTo<Client>,
session: &Session,
supports_goose_custom_notifications: bool,
client_requests_tool_call_label_enrichment: bool,
) -> Result<(), agent_client_protocol::Error> {
let session_id = SessionId::new(session.id.clone());
let tool_call_notifier = ToolCallNotifier::new(cx, &session_id);
@@ -112,21 +144,11 @@ fn replay_conversation_to_client(
MessageContent::ToolRequest(tool_request) => {
replay_tool_requests.insert(tool_request.id.clone(), tool_request.clone());
let mut tool_call = build_initial_tool_call(tool_request);
let mut meta = tool_call.meta.take();
if let Some(chain_summary) = tool_request.generated_chain_summary() {
let goose_meta = meta
.get_or_insert_default()
.entry("goose".to_string())
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if !goose_meta.is_object() {
*goose_meta = serde_json::Value::Object(serde_json::Map::new());
}
goose_meta
.as_object_mut()
.expect("goose metadata was initialized as an object")
.extend([tool_chain_summary(&chain_summary)]);
}
let mut tool_call = build_replayed_tool_call(
tool_request,
client_requests_tool_call_label_enrichment,
);
let meta = tool_call.meta.take();
let tool_call = tool_call.meta(merge_replay_message_meta(meta, message));
tool_call_notifier.send_initial(tool_call)?;
@@ -201,7 +223,12 @@ impl GooseAcpAgent {
.prepare_session_for_activation(session, args.cwd.clone(), args.mcp_servers, true)
.await?;
replay_conversation_to_client(cx, &session, self.supports_goose_custom_notifications())?;
replay_conversation_to_client(
cx,
&session,
self.supports_goose_custom_notifications(),
self.requests_tool_call_label_enrichment(),
)?;
let (agent, extension_results) = self.prepare_acp_session_agent(cx, &session).await?;
self.apply_session_recipe(&agent, &session).await?;
self.register_acp_session(session_id_str.clone(), agent.clone())
@@ -238,6 +265,63 @@ impl GooseAcpAgent {
#[cfg(test)]
mod tests {
use super::*;
use rmcp::model::CallToolRequestParams;
fn persisted_enriched_tool_request() -> ToolRequest {
ToolRequest {
id: "req_first".to_string(),
tool_call: Ok(CallToolRequestParams::new("developer__shell")),
metadata: None,
tool_meta: Some(serde_json::json!({
(crate::conversation::message::TOOL_META_TITLE_KEY): "applied dark mode polish",
(crate::conversation::message::TOOL_META_CHAIN_SUMMARY_KEY): {
"summary": "applied dark mode polish",
"count": 3,
},
})),
}
}
#[test]
fn replay_includes_persisted_enrichment_when_requested() {
let tool_call = build_replayed_tool_call(&persisted_enriched_tool_request(), true);
let goose = tool_call
.meta
.as_ref()
.and_then(|meta| meta.get("goose"))
.expect("valid initial tool call should contain goose metadata");
assert_eq!(tool_call.title, "applied dark mode polish");
assert_eq!(
goose.get("toolCall"),
Some(
&serde_json::json!({ "toolName": "developer__shell", "extensionName": "developer" })
),
);
assert_eq!(
goose.get("toolChainSummary"),
Some(&serde_json::json!({ "summary": "applied dark mode polish", "count": 3 })),
);
}
#[test]
fn replay_omits_persisted_enrichment_when_not_requested() {
let tool_call = build_replayed_tool_call(&persisted_enriched_tool_request(), false);
let goose = tool_call
.meta
.as_ref()
.and_then(|meta| meta.get("goose"))
.expect("valid initial tool call should contain goose metadata");
assert_eq!(tool_call.title, "developer: shell");
assert_eq!(
goose.get("toolCall"),
Some(
&serde_json::json!({ "toolName": "developer__shell", "extensionName": "developer" })
),
);
assert_eq!(goose.get("toolChainSummary"), None);
}
#[test]
fn merge_replay_message_meta_preserves_existing_goose_meta() {
@@ -82,7 +82,10 @@ pub(crate) fn goose_tool_call_meta(tool_request: &ToolRequest) -> Option<Meta> {
Some(meta)
}
pub(crate) fn build_initial_tool_call(tool_request: &ToolRequest) -> ToolCall {
pub(crate) fn build_initial_tool_call(
tool_request: &ToolRequest,
include_generated_title: bool,
) -> ToolCall {
let tool_name = match &tool_request.tool_call {
Ok(tool_call) => tool_call.name.to_string(),
Err(_) => "error".to_string(),
@@ -96,10 +99,14 @@ pub(crate) fn build_initial_tool_call(tool_request: &ToolRequest) -> ToolCall {
let default_tool_call_title = default_tool_title(&tool_name, args_value.as_ref());
let goose_meta = goose_tool_call_meta(tool_request);
let initial_title = tool_request
.generated_title()
.map(|s| s.to_string())
.unwrap_or(default_tool_call_title);
let initial_title = if include_generated_title {
tool_request
.generated_title()
.map(str::to_string)
.unwrap_or(default_tool_call_title)
} else {
default_tool_call_title
};
let mut tool_call = ToolCall::new(ToolCallId::new(tool_request.id.clone()), initial_title)
.status(ToolCallStatus::Pending);
@@ -368,7 +375,7 @@ mod tests {
tool_meta: Some(serde_json::json!({"goose_extension": "developer"})),
};
let tool_call = build_initial_tool_call(&request);
let tool_call = build_initial_tool_call(&request, false);
assert_eq!(tool_call.title, "edit · /src/main.rs");
assert_eq!(tool_call.status, ToolCallStatus::Pending);
@@ -388,7 +395,7 @@ mod tests {
}
#[test]
fn uses_generated_title() {
fn uses_generated_title_when_enrichment_is_enabled() {
let arguments = json_object(vec![("command", serde_json::json!("cargo test"))]);
let request = ToolRequest {
id: "req_1".to_string(),
@@ -401,11 +408,30 @@ mod tests {
})),
};
let tool_call = build_initial_tool_call(&request);
let tool_call = build_initial_tool_call(&request, true);
assert_eq!(tool_call.title, "running focused tests");
}
#[test]
fn uses_default_title_when_enrichment_is_disabled() {
let arguments = json_object(vec![("command", serde_json::json!("cargo test"))]);
let request = ToolRequest {
id: "req_1".to_string(),
tool_call: Ok(
CallToolRequestParams::new("developer__shell").with_arguments(arguments)
),
metadata: None,
tool_meta: Some(serde_json::json!({
(TOOL_META_TITLE_KEY): "running focused tests",
})),
};
let tool_call = build_initial_tool_call(&request, false);
assert_eq!(tool_call.title, "developer: shell · cargo test");
}
#[test]
fn handles_invalid_request() {
let request = ToolRequest {
@@ -415,7 +441,7 @@ mod tests {
tool_meta: None,
};
let tool_call = build_initial_tool_call(&request);
let tool_call = build_initial_tool_call(&request, false);
assert_eq!(tool_call.title, "error");
assert_eq!(tool_call.status, ToolCallStatus::Pending);
@@ -437,7 +463,7 @@ mod tests {
metadata: None,
tool_meta: None,
};
let initial = build_initial_tool_call(&request);
let initial = build_initial_tool_call(&request, false);
let permission = build_permission_tool_call_update(
&request.id,
-1
View File
@@ -1138,7 +1138,6 @@ config_value!(GOOSE_PROMPT_EDITOR, Option<String>);
config_value!(GOOSE_PROMPT_EDITOR_ALWAYS, Option<bool>);
config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize);
config_value!(GOOSE_DISABLE_SESSION_NAMING, bool);
config_value!(GOOSE_DISABLE_TOOL_CALL_SUMMARY, bool);
impl Config {
pub fn get_goose_context_limit(&self) -> Result<Option<usize>, ConfigError> {
@@ -160,7 +160,6 @@ These variables control how goose manages conversation sessions and context.
| `GOOSE_MAX_BACKGROUND_TASKS` | Sets the maximum number of concurrent background [subagent](/docs/guides/context-engineering/subagents) tasks goose can run at once | Integer (e.g., 1, 5, 10) | 5 |
| `CONTEXT_FILE_NAMES` | Specifies custom filenames for [hint/context files](/docs/guides/context-engineering/using-goosehints#custom-context-files) | JSON array of strings (e.g., `["CLAUDE.md", ".goosehints"]`) | `[".goosehints", "AGENTS.md"]` |
| `GOOSE_DISABLE_SESSION_NAMING` | Disables automatic AI-generated session naming; avoids the background model call and keeps the default "CLI Session" (goose CLI) or "New Chat" (goose Desktop) | "1", "true" (case-insensitive) to enable | false |
| `GOOSE_DISABLE_TOOL_CALL_SUMMARY` | Disables the per-tool-call AI-generated summary title, keeping the fallback title instead. Saves one provider call per tool invocation. | "1", "true" (case-insensitive) to enable | false |
| `GOOSE_PROMPT_EDITOR` | [External editor](/docs/guides/goose-cli-commands#external-editor-mode) to use for composing prompts instead of CLI input | Editor command (e.g., "vim", "code --wait") | Unset (uses CLI input) |
| `GOOSE_CLI_THEME` | [Theme](/docs/guides/goose-cli-commands#themes) for CLI response markdown | "light", "dark", "ansi" | "ansi" |
| `GOOSE_CLI_LIGHT_THEME` | Custom [bat theme](https://github.com/sharkdp/bat#adding-new-themes) for syntax highlighting when using light mode | bat theme name (e.g., "Solarized (light)", "OneHalfLight") | "GitHub" |