Add summary/title to apply_file_diffs tool and propagate is_error on tool results

The apply_file_diffs tool now includes a required "summary" field so the
model provides a brief description of edits. This surfaces as the title
on the diff panel instead of showing only +/- line counts.

Also propagates is_error from tool result content back to the model so it
can distinguish failed tool calls from successful ones.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-27 11:29:50 -05:00
co-authored by Claude Opus 4.6
parent d55befec1c
commit 8e2fce08aa
4 changed files with 171 additions and 76 deletions
+2 -1
View File
@@ -667,9 +667,10 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"summary": { "type": "string", "description": "A brief summary of what these edits accomplish" },
"diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string" }, "search": { "type": "string" }, "replace": { "type": "string" } }, "required": ["file_path", "search", "replace"] } } "diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string" }, "search": { "type": "string" }, "replace": { "type": "string" } }, "required": ["file_path", "search", "replace"] } }
}, },
"required": ["diffs"] "required": ["summary", "diffs"]
}), }),
}, },
"grep" => ToolDefinition { "grep" => ToolDefinition {
+162 -73
View File
@@ -55,13 +55,13 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
result, result,
)) => { )) => {
if !result.tool_call_id.is_empty() { if !result.tool_call_id.is_empty() {
let content = extract_tool_result_content(result); let (content, is_error) = extract_tool_result_content(result);
tool_results.push(ConversationMessage { tool_results.push(ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::ToolResult { content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(), tool_use_id: result.tool_call_id.clone(),
content, content,
is_error: false, is_error,
}, },
}); });
} }
@@ -306,7 +306,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
result, result,
)) => { )) => {
if !result.tool_call_id.is_empty() { if !result.tool_call_id.is_empty() {
let content = extract_tool_result_content(result); let (content, _is_error) = extract_tool_result_content(result);
results.push(api::Message { results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(), task_id: task_id.clone(),
@@ -370,7 +370,7 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
} }
api::request::input::Type::ToolCallResult(result) => { api::request::input::Type::ToolCallResult(result) => {
if !result.tool_call_id.is_empty() { if !result.tool_call_id.is_empty() {
let content = extract_tool_result_content(result); let (content, _is_error) = extract_tool_result_content(result);
results.push(api::Message { results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(), task_id: task_id.clone(),
@@ -1000,9 +1000,10 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
input_schema: serde_json::json!({ input_schema: serde_json::json!({
"type": "object", "type": "object",
"properties": { "properties": {
"summary": { "type": "string", "description": "A brief summary of what these edits accomplish (e.g. 'Add error handling to parse_config')" },
"diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Absolute path to the file" }, "search": { "type": "string", "description": "Exact text to find (must match uniquely). Empty string to create a new file." }, "replace": { "type": "string", "description": "Text to replace with" } }, "required": ["file_path", "search", "replace"] }, "description": "Array of file edits to apply" } "diffs": { "type": "array", "items": { "type": "object", "properties": { "file_path": { "type": "string", "description": "Absolute path to the file" }, "search": { "type": "string", "description": "Exact text to find (must match uniquely). Empty string to create a new file." }, "replace": { "type": "string", "description": "Text to replace with" } }, "required": ["file_path", "search", "replace"] }, "description": "Array of file edits to apply" }
}, },
"required": ["diffs"] "required": ["summary", "diffs"]
}), }),
}, },
ToolDefinition { ToolDefinition {
@@ -1179,92 +1180,119 @@ pub fn default_tool_definitions() -> Vec<ToolDefinition> {
] ]
} }
fn extract_tool_result_content(result: &api::request::input::ToolCallResult) -> String { /// Returns `(content_text, is_error)` for a tool call result.
fn extract_tool_result_content(result: &api::request::input::ToolCallResult) -> (String, bool) {
if let Some(result_type) = &result.result { if let Some(result_type) = &result.result {
match result_type { match result_type {
api::request::input::tool_call_result::Result::RunShellCommand(cmd_result) => { api::request::input::tool_call_result::Result::RunShellCommand(cmd_result) => {
match &cmd_result.result { match &cmd_result.result {
Some(api::run_shell_command_result::Result::CommandFinished(finished)) => { Some(api::run_shell_command_result::Result::CommandFinished(finished)) => {
if finished.output.is_empty() { let content = if finished.output.is_empty() {
format!("Exit code: {}\n(no output)", finished.exit_code) format!("Exit code: {}\n(no output)", finished.exit_code)
} else { } else {
format!("Exit code: {}\n{}", finished.exit_code, finished.output) format!("Exit code: {}\n{}", finished.exit_code, finished.output)
} };
let is_error = finished.exit_code != 0;
(content, is_error)
} }
Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot( Some(api::run_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot, snapshot,
)) => snapshot.output.clone(), )) => (snapshot.output.clone(), false),
_ => "Command completed.".to_string(), Some(api::run_shell_command_result::Result::PermissionDenied(denied)) => {
let reason = match &denied.reason {
Some(api::permission_denied::Reason::DenylistedCommand(())) => {
"command is on the deny list"
}
_ => "permission denied",
};
(format!("Error: Command not executed — {reason}."), true)
}
_ => ("Command completed.".to_string(), false),
} }
} }
api::request::input::tool_call_result::Result::ReadFiles(read_result) => { api::request::input::tool_call_result::Result::ReadFiles(read_result) => {
match &read_result.result { match &read_result.result {
Some(api::read_files_result::Result::TextFilesSuccess(success)) => success Some(api::read_files_result::Result::TextFilesSuccess(success)) => (
.files success
.iter() .files
.map(|f| format!("{}:\n{}", f.file_path, f.content)) .iter()
.collect::<Vec<_>>() .map(|f| format!("{}:\n{}", f.file_path, f.content))
.join("\n\n"), .collect::<Vec<_>>()
Some(api::read_files_result::Result::AnyFilesSuccess(success)) => success .join("\n\n"),
.files false,
.iter() ),
.filter_map(|f| match &f.content { Some(api::read_files_result::Result::AnyFilesSuccess(success)) => (
Some(api::any_file_content::Content::TextContent(t)) => { success
Some(format!("{}:\n{}", t.file_path, t.content)) .files
} .iter()
_ => None, .filter_map(|f| match &f.content {
}) Some(api::any_file_content::Content::TextContent(t)) => {
.collect::<Vec<_>>() Some(format!("{}:\n{}", t.file_path, t.content))
.join("\n\n"), }
_ => "Failed to read files.".to_string(), _ => None,
})
.collect::<Vec<_>>()
.join("\n\n"),
false,
),
Some(api::read_files_result::Result::Error(error)) => {
(format!("Error reading files: {}", error.message), true)
}
_ => ("Failed to read files.".to_string(), true),
} }
} }
api::request::input::tool_call_result::Result::Grep(grep_result) => { api::request::input::tool_call_result::Result::Grep(grep_result) => {
match &grep_result.result { match &grep_result.result {
Some(api::grep_result::Result::Success(success)) => { Some(api::grep_result::Result::Success(success)) => {
if success.matched_files.is_empty() { if success.matched_files.is_empty() {
"No matches found.".to_string() ("No matches found.".to_string(), false)
} else { } else {
success (
.matched_files success
.iter() .matched_files
.map(|f| { .iter()
let lines: String = f .map(|f| {
.matched_lines let lines: String = f
.iter() .matched_lines
.map(|l| format!(" line {}", l.line_number)) .iter()
.collect::<Vec<_>>() .map(|l| format!(" line {}", l.line_number))
.join(", "); .collect::<Vec<_>>()
format!("{} (matches at: {})", f.file_path, lines) .join(", ");
}) format!("{} (matches at: {})", f.file_path, lines)
.collect::<Vec<_>>() })
.join("\n") .collect::<Vec<_>>()
.join("\n"),
false,
)
} }
} }
Some(api::grep_result::Result::Error(error)) => { Some(api::grep_result::Result::Error(error)) => {
format!("Grep error: {}", error.message) (format!("Grep error: {}", error.message), true)
} }
None => "Grep completed (no result).".to_string(), None => ("Grep completed (no result).".to_string(), false),
} }
} }
api::request::input::tool_call_result::Result::FileGlobV2(glob_result) => { api::request::input::tool_call_result::Result::FileGlobV2(glob_result) => {
match &glob_result.result { match &glob_result.result {
Some(api::file_glob_v2_result::Result::Success(success)) => { Some(api::file_glob_v2_result::Result::Success(success)) => {
if success.matched_files.is_empty() { if success.matched_files.is_empty() {
"No files matched.".to_string() ("No files matched.".to_string(), false)
} else { } else {
success (
.matched_files success
.iter() .matched_files
.map(|f| f.file_path.as_str()) .iter()
.collect::<Vec<_>>() .map(|f| f.file_path.as_str())
.join("\n") .collect::<Vec<_>>()
.join("\n"),
false,
)
} }
} }
Some(api::file_glob_v2_result::Result::Error(error)) => { Some(api::file_glob_v2_result::Result::Error(error)) => {
format!("File glob error: {}", error.message) (format!("File glob error: {}", error.message), true)
} }
None => "File glob completed (no result).".to_string(), None => ("File glob completed (no result).".to_string(), false),
} }
} }
api::request::input::tool_call_result::Result::ApplyFileDiffs(diff_result) => { api::request::input::tool_call_result::Result::ApplyFileDiffs(diff_result) => {
@@ -1280,35 +1308,35 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
parts.push(format!("Deleted: {}", f.file_path)); parts.push(format!("Deleted: {}", f.file_path));
} }
if parts.is_empty() { if parts.is_empty() {
"Diffs applied successfully.".to_string() ("Diffs applied successfully.".to_string(), false)
} else { } else {
parts.join("\n") (parts.join("\n"), false)
} }
} }
Some(api::apply_file_diffs_result::Result::Error(error)) => { Some(api::apply_file_diffs_result::Result::Error(error)) => {
format!("Apply diffs error: {}", error.message) (format!("Apply diffs error: {}", error.message), true)
} }
None => "Apply diffs completed.".to_string(), None => ("Apply diffs completed.".to_string(), false),
} }
} }
api::request::input::tool_call_result::Result::FileGlob(glob_result) => { api::request::input::tool_call_result::Result::FileGlob(glob_result) => {
match &glob_result.result { match &glob_result.result {
Some(api::file_glob_result::Result::Success(success)) => { Some(api::file_glob_result::Result::Success(success)) => {
if success.matched_files.is_empty() { if success.matched_files.is_empty() {
"No files matched.".to_string() ("No files matched.".to_string(), false)
} else { } else {
success.matched_files.clone() (success.matched_files.clone(), false)
} }
} }
Some(api::file_glob_result::Result::Error(error)) => { Some(api::file_glob_result::Result::Error(error)) => {
format!("File glob error: {}", error.message) (format!("File glob error: {}", error.message), true)
} }
None => "File glob completed (no result).".to_string(), None => ("File glob completed (no result).".to_string(), false),
} }
} }
api::request::input::tool_call_result::Result::CallMcpTool(mcp_result) => { api::request::input::tool_call_result::Result::CallMcpTool(mcp_result) => {
match &mcp_result.result { match &mcp_result.result {
Some(api::call_mcp_tool_result::Result::Success(success)) => { Some(api::call_mcp_tool_result::Result::Success(success)) => (
success success
.results .results
.iter() .iter()
@@ -1319,18 +1347,61 @@ fn extract_tool_result_content(result: &api::request::input::ToolCallResult) ->
_ => None, _ => None,
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .join("\n"),
} false,
),
Some(api::call_mcp_tool_result::Result::Error(error)) => { Some(api::call_mcp_tool_result::Result::Error(error)) => {
format!("MCP tool error: {}", error.message) (format!("MCP tool error: {}", error.message), true)
} }
None => "MCP tool completed.".to_string(), None => ("MCP tool completed.".to_string(), false),
} }
} }
_ => "Tool completed successfully.".to_string(), api::request::input::tool_call_result::Result::SearchCodebase(search_result) => {
match &search_result.result {
Some(api::search_codebase_result::Result::Success(success)) => (
success
.files
.iter()
.map(|f| format!("{}:\n{}", f.file_path, f.content))
.collect::<Vec<_>>()
.join("\n\n"),
false,
),
Some(api::search_codebase_result::Result::Error(error)) => {
(format!("Search codebase error: {}", error.message), true)
}
None => ("Search codebase completed (no result).".to_string(), false),
}
}
api::request::input::tool_call_result::Result::WriteToLongRunningShellCommand(
write_result,
) => match &write_result.result {
Some(
api::write_to_long_running_shell_command_result::Result::LongRunningCommandSnapshot(
snapshot,
),
) => (snapshot.output.clone(), false),
Some(
api::write_to_long_running_shell_command_result::Result::CommandFinished(
finished,
),
) => {
let content = format!(
"Exit code: {}\n{}",
finished.exit_code, finished.output
);
let is_error = finished.exit_code != 0;
(content, is_error)
}
Some(api::write_to_long_running_shell_command_result::Result::Error(_)) => {
("Error: shell command not found.".to_string(), true)
}
None => ("Write to shell command completed.".to_string(), false),
},
_ => ("Tool completed successfully.".to_string(), false),
} }
} else { } else {
"Tool completed.".to_string() ("Tool completed.".to_string(), false)
} }
} }
@@ -1356,6 +1427,22 @@ pub fn extract_messages_from_request(request: &api::Request) -> Vec<Conversation
messages messages
} }
/// Heuristic to detect whether a serialized `ServerResult` text represents an error.
/// Used during session restoration where the structured result type is lost and we only
/// have the serialized text to inspect.
fn is_server_result_error(text: &str) -> bool {
let lower = text.to_lowercase();
lower.starts_with("error:")
|| lower.starts_with("error reading")
|| lower.starts_with("grep error:")
|| lower.starts_with("file glob error:")
|| lower.starts_with("apply diffs error:")
|| lower.starts_with("mcp tool error:")
|| lower.starts_with("search codebase error:")
|| lower.starts_with("failed to read files")
|| (lower.starts_with("exit code:") && !lower.starts_with("exit code: 0"))
}
/// Converts a proto `api::Message` into a `ConversationMessage` for the Bedrock message history. /// Converts a proto `api::Message` into a `ConversationMessage` for the Bedrock message history.
/// Used to rebuild the message history from persisted task messages on session restore. /// Used to rebuild the message history from persisted task messages on session restore.
pub fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage> { pub fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage> {
@@ -1381,18 +1468,20 @@ pub fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage>
}) })
} }
api::message::Message::ToolCallResult(result) => { api::message::Message::ToolCallResult(result) => {
let content = match &result.result { let (content, is_error) = match &result.result {
Some(api::message::tool_call_result::Result::Server(s)) => { Some(api::message::tool_call_result::Result::Server(s)) => {
s.serialized_result.clone() let text = s.serialized_result.clone();
let is_err = is_server_result_error(&text);
(text, is_err)
} }
_ => "Tool completed.".to_string(), _ => ("Tool completed.".to_string(), false),
}; };
Some(ConversationMessage { Some(ConversationMessage {
role: MessageRole::User, role: MessageRole::User,
content: MessageContent::ToolResult { content: MessageContent::ToolResult {
tool_use_id: result.tool_call_id.clone(), tool_use_id: result.tool_call_id.clone(),
content, content,
is_error: false, is_error,
}, },
}) })
} }
+6 -1
View File
@@ -793,6 +793,11 @@ fn build_tool_call_message(
)) ))
} }
"apply_file_diffs" => { "apply_file_diffs" => {
let summary = input
.get("summary")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let diffs = input let diffs = input
.get("diffs") .get("diffs")
.and_then(|v| v.as_array()) .and_then(|v| v.as_array())
@@ -818,7 +823,7 @@ fn build_tool_call_message(
.unwrap_or_default(); .unwrap_or_default();
Some(api::message::tool_call::Tool::ApplyFileDiffs( Some(api::message::tool_call::Tool::ApplyFileDiffs(
api::message::tool_call::ApplyFileDiffs { api::message::tool_call::ApplyFileDiffs {
summary: String::new(), summary,
diffs, diffs,
new_files: vec![], new_files: vec![],
deleted_files: vec![], deleted_files: vec![],
+1 -1
View File
@@ -126,7 +126,7 @@ impl From<api::message::tool_call::ApplyFileDiffs> for AIAgentActionType {
.chain(new_file_edits) .chain(new_file_edits)
.chain(file_deletes) .chain(file_deletes)
.collect(), .collect(),
title: Some(value.summary), title: value.summary.none_if_default(),
} }
} }
} }