Tighten tool parity for agent handoffs and notebook edits

Normalize Agent subagent aliases to Claude Code style built-in names, expose richer handoff metadata, teach ToolSearch to match canonical tool aliases, and polish NotebookEdit so delete does not require source and insert without a target appends cleanly. These are small parity-oriented behavior fixes confined to the tools crate.\n\nConstraint: Must not touch unrelated dirty api files in this worktree\nConstraint: Keep the change limited to rust/crates/tools\nRejected: Rework Agent into a real scheduler | outside this slice and not a small parity polish\nRejected: Add broad new tool surface area | request calls for small real parity improvements only\nConfidence: high\nScope-risk: narrow\nReversibility: clean\nDirective: Keep Agent built-in type normalization aligned with upstream naming aliases before expanding execution semantics\nTested: cargo test -p tools\nNot-tested: integration against a real upstream Claude Code runtime
This commit is contained in:
Yeachan-Heo
2026-03-31 20:20:22 +00:00
parent 14757e0780
commit 0346b7dd3a

View File

@@ -259,7 +259,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
"cell_type": { "type": "string", "enum": ["code", "markdown"] }, "cell_type": { "type": "string", "enum": ["code", "markdown"] },
"edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] } "edit_mode": { "type": "string", "enum": ["replace", "insert", "delete"] }
}, },
"required": ["notebook_path", "new_source"], "required": ["notebook_path"],
"additionalProperties": false "additionalProperties": false
}), }),
}, },
@@ -481,7 +481,7 @@ struct ToolSearchInput {
struct NotebookEditInput { struct NotebookEditInput {
notebook_path: String, notebook_path: String,
cell_id: Option<String>, cell_id: Option<String>,
new_source: String, new_source: Option<String>,
cell_type: Option<NotebookCellType>, cell_type: Option<NotebookCellType>,
edit_mode: Option<NotebookEditMode>, edit_mode: Option<NotebookEditMode>,
} }
@@ -565,12 +565,17 @@ struct AgentOutput {
status: String, status: String,
#[serde(rename = "outputFile")] #[serde(rename = "outputFile")]
output_file: String, output_file: String,
#[serde(rename = "manifestFile")]
manifest_file: String,
#[serde(rename = "createdAt")]
created_at: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct ToolSearchOutput { struct ToolSearchOutput {
matches: Vec<String>, matches: Vec<String>,
query: String, query: String,
normalized_query: String,
#[serde(rename = "total_deferred_tools")] #[serde(rename = "total_deferred_tools")]
total_deferred_tools: usize, total_deferred_tools: usize,
#[serde(rename = "pending_mcp_servers")] #[serde(rename = "pending_mcp_servers")]
@@ -581,7 +586,7 @@ struct ToolSearchOutput {
struct NotebookEditOutput { struct NotebookEditOutput {
new_source: String, new_source: String,
cell_id: Option<String>, cell_id: Option<String>,
cell_type: NotebookCellType, cell_type: Option<NotebookCellType>,
language: String, language: String,
edit_mode: String, edit_mode: String,
error: Option<String>, error: Option<String>,
@@ -1101,21 +1106,27 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?; std::fs::create_dir_all(&output_dir).map_err(|error| error.to_string())?;
let output_file = output_dir.join(format!("{agent_id}.md")); let output_file = output_dir.join(format!("{agent_id}.md"));
let manifest_file = output_dir.join(format!("{agent_id}.json")); let manifest_file = output_dir.join(format!("{agent_id}.json"));
let normalized_subagent_type = normalize_subagent_type(input.subagent_type.as_deref());
let agent_name = input let agent_name = input
.name .name
.clone() .clone()
.unwrap_or_else(|| slugify_agent_name(&input.description)); .unwrap_or_else(|| slugify_agent_name(&input.description));
let created_at = iso8601_now();
let output_contents = format!( let output_contents = format!(
"# Agent Task\n\n- id: {}\n- name: {}\n- description: {}\n- subagent_type: {}\n\n## Prompt\n\n{}\n", "# Agent Task
agent_id,
agent_name, - id: {}
input.description, - name: {}
input - description: {}
.subagent_type - subagent_type: {}
.clone() - created_at: {}
.unwrap_or_else(|| String::from("general-purpose")),
input.prompt ## Prompt
{}
",
agent_id, agent_name, input.description, normalized_subagent_type, created_at, input.prompt
); );
std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?; std::fs::write(&output_file, output_contents).map_err(|error| error.to_string())?;
@@ -1123,10 +1134,12 @@ fn execute_agent(input: AgentInput) -> Result<AgentOutput, String> {
agent_id, agent_id,
name: agent_name, name: agent_name,
description: input.description, description: input.description,
subagent_type: input.subagent_type, subagent_type: Some(normalized_subagent_type),
model: input.model, model: input.model,
status: String::from("queued"), status: String::from("queued"),
output_file: output_file.display().to_string(), output_file: output_file.display().to_string(),
manifest_file: manifest_file.display().to_string(),
created_at,
}; };
std::fs::write( std::fs::write(
&manifest_file, &manifest_file,
@@ -1141,11 +1154,13 @@ fn execute_tool_search(input: ToolSearchInput) -> ToolSearchOutput {
let deferred = deferred_tool_specs(); let deferred = deferred_tool_specs();
let max_results = input.max_results.unwrap_or(5).max(1); let max_results = input.max_results.unwrap_or(5).max(1);
let query = input.query.trim().to_string(); let query = input.query.trim().to_string();
let normalized_query = normalize_tool_search_query(&query);
let matches = search_tool_specs(&query, max_results, &deferred); let matches = search_tool_specs(&query, max_results, &deferred);
ToolSearchOutput { ToolSearchOutput {
matches, matches,
query, query,
normalized_query,
total_deferred_tools: deferred.len(), total_deferred_tools: deferred.len(),
pending_mcp_servers: None, pending_mcp_servers: None,
} }
@@ -1171,9 +1186,10 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
.map(str::trim) .map(str::trim)
.filter(|part| !part.is_empty()) .filter(|part| !part.is_empty())
.filter_map(|wanted| { .filter_map(|wanted| {
let wanted = canonical_tool_token(wanted);
specs specs
.iter() .iter()
.find(|spec| spec.name.eq_ignore_ascii_case(wanted)) .find(|spec| canonical_tool_token(spec.name) == wanted)
.map(|spec| spec.name.to_string()) .map(|spec| spec.name.to_string())
}) })
.take(max_results) .take(max_results)
@@ -1201,13 +1217,20 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
.iter() .iter()
.filter_map(|spec| { .filter_map(|spec| {
let name = spec.name.to_lowercase(); let name = spec.name.to_lowercase();
let haystack = format!("{name} {}", spec.description.to_lowercase()); let canonical_name = canonical_tool_token(spec.name);
let normalized_description = normalize_tool_search_query(spec.description);
let haystack = format!(
"{name} {} {canonical_name}",
spec.description.to_lowercase()
);
let normalized_haystack = format!("{canonical_name} {normalized_description}");
if required.iter().any(|term| !haystack.contains(term)) { if required.iter().any(|term| !haystack.contains(term)) {
return None; return None;
} }
let mut score = 0_i32; let mut score = 0_i32;
for term in &terms { for term in &terms {
let canonical_term = canonical_tool_token(term);
if haystack.contains(term) { if haystack.contains(term) {
score += 2; score += 2;
} }
@@ -1217,6 +1240,12 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
if name.contains(term) { if name.contains(term) {
score += 4; score += 4;
} }
if canonical_name == canonical_term {
score += 12;
}
if normalized_haystack.contains(&canonical_term) {
score += 3;
}
} }
if score == 0 && !lowered.is_empty() { if score == 0 && !lowered.is_empty() {
@@ -1226,7 +1255,7 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
scored.sort_by(|left, right| right.cmp(left)); scored.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
scored scored
.into_iter() .into_iter()
.map(|(_, name)| name) .map(|(_, name)| name)
@@ -1234,6 +1263,28 @@ fn search_tool_specs(query: &str, max_results: usize, specs: &[ToolSpec]) -> Vec
.collect() .collect()
} }
fn normalize_tool_search_query(query: &str) -> String {
query
.trim()
.split(|ch: char| ch.is_whitespace() || ch == ',')
.filter(|term| !term.is_empty())
.map(canonical_tool_token)
.collect::<Vec<_>>()
.join(" ")
}
fn canonical_tool_token(value: &str) -> String {
let mut canonical = value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect::<String>();
if let Some(stripped) = canonical.strip_suffix("tool") {
canonical = stripped.to_string();
}
canonical
}
fn agent_store_dir() -> Result<std::path::PathBuf, String> { fn agent_store_dir() -> Result<std::path::PathBuf, String> {
if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") { if let Ok(path) = std::env::var("CLAWD_AGENT_STORE") {
return Ok(std::path::PathBuf::from(path)); return Ok(std::path::PathBuf::from(path));
@@ -1267,6 +1318,33 @@ fn slugify_agent_name(description: &str) -> String {
out.trim_matches('-').chars().take(32).collect() out.trim_matches('-').chars().take(32).collect()
} }
fn normalize_subagent_type(subagent_type: Option<&str>) -> String {
let trimmed = subagent_type.map(str::trim).unwrap_or_default();
if trimmed.is_empty() {
return String::from("general-purpose");
}
match canonical_tool_token(trimmed).as_str() {
"general" | "generalpurpose" | "generalpurposeagent" => String::from("general-purpose"),
"explore" | "explorer" | "exploreagent" => String::from("Explore"),
"plan" | "planagent" => String::from("Plan"),
"verification" | "verificationagent" | "verify" | "verifier" => {
String::from("Verification")
}
"claudecodeguide" | "claudecodeguideagent" | "guide" => String::from("claude-code-guide"),
"statusline" | "statuslinesetup" => String::from("statusline-setup"),
_ => trimmed.to_string(),
}
}
fn iso8601_now() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> { fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput, String> {
let path = std::path::PathBuf::from(&input.notebook_path); let path = std::path::PathBuf::from(&input.notebook_path);
if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") { if path.extension().and_then(|ext| ext.to_str()) != Some("ipynb") {
@@ -1291,38 +1369,35 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
.ok_or_else(|| String::from("Notebook cells array not found"))?; .ok_or_else(|| String::from("Notebook cells array not found"))?;
let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace); let edit_mode = input.edit_mode.unwrap_or(NotebookEditMode::Replace);
let target_index = resolve_cell_index(cells, input.cell_id.as_deref(), edit_mode)?; let target_index = match input.cell_id.as_deref() {
let resolved_cell_type = input.cell_type.unwrap_or_else(|| { Some(cell_id) => Some(resolve_cell_index(cells, Some(cell_id), edit_mode)?),
cells None if matches!(
.get(target_index) edit_mode,
.and_then(|cell| cell.get("cell_type")) NotebookEditMode::Replace | NotebookEditMode::Delete
.and_then(serde_json::Value::as_str) ) =>
.map(|kind| { {
if kind == "markdown" { Some(resolve_cell_index(cells, None, edit_mode)?)
NotebookCellType::Markdown
} else {
NotebookCellType::Code
} }
}) None => None,
};
let resolved_cell_type = match edit_mode {
NotebookEditMode::Delete => None,
NotebookEditMode::Insert => Some(input.cell_type.unwrap_or(NotebookCellType::Code)),
NotebookEditMode::Replace => Some(input.cell_type.unwrap_or_else(|| {
target_index
.and_then(|index| cells.get(index))
.and_then(cell_kind)
.unwrap_or(NotebookCellType::Code) .unwrap_or(NotebookCellType::Code)
}); })),
};
let new_source = require_notebook_source(input.new_source, edit_mode)?;
let cell_id = match edit_mode { let cell_id = match edit_mode {
NotebookEditMode::Insert => { NotebookEditMode::Insert => {
let resolved_cell_type = resolved_cell_type.expect("insert cell type");
let new_id = make_cell_id(cells.len()); let new_id = make_cell_id(cells.len());
let new_cell = json!({ let new_cell = build_notebook_cell(&new_id, resolved_cell_type, &new_source);
"cell_type": match resolved_cell_type { NotebookCellType::Code => "code", NotebookCellType::Markdown => "markdown" }, let insert_at = target_index.map_or(cells.len(), |index| index + 1);
"id": new_id,
"metadata": {},
"source": source_lines(&input.new_source),
"outputs": [],
"execution_count": serde_json::Value::Null,
});
let insert_at = if input.cell_id.is_some() {
target_index + 1
} else {
0
};
cells.insert(insert_at, new_cell); cells.insert(insert_at, new_cell);
cells cells
.get(insert_at) .get(insert_at)
@@ -1331,21 +1406,38 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
.map(ToString::to_string) .map(ToString::to_string)
} }
NotebookEditMode::Delete => { NotebookEditMode::Delete => {
let removed = cells.remove(target_index); let removed = cells.remove(target_index.expect("delete target index"));
removed removed
.get("id") .get("id")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.map(ToString::to_string) .map(ToString::to_string)
} }
NotebookEditMode::Replace => { NotebookEditMode::Replace => {
let resolved_cell_type = resolved_cell_type.expect("replace cell type");
let cell = cells let cell = cells
.get_mut(target_index) .get_mut(target_index.expect("replace target index"))
.ok_or_else(|| String::from("Cell index out of range"))?; .ok_or_else(|| String::from("Cell index out of range"))?;
cell["source"] = serde_json::Value::Array(source_lines(&input.new_source)); cell["source"] = serde_json::Value::Array(source_lines(&new_source));
cell["cell_type"] = serde_json::Value::String(match resolved_cell_type { cell["cell_type"] = serde_json::Value::String(match resolved_cell_type {
NotebookCellType::Code => String::from("code"), NotebookCellType::Code => String::from("code"),
NotebookCellType::Markdown => String::from("markdown"), NotebookCellType::Markdown => String::from("markdown"),
}); });
match resolved_cell_type {
NotebookCellType::Code => {
if !cell.get("outputs").is_some_and(serde_json::Value::is_array) {
cell["outputs"] = json!([]);
}
if !cell.get("execution_count").is_some() {
cell["execution_count"] = serde_json::Value::Null;
}
}
NotebookCellType::Markdown => {
if let Some(object) = cell.as_object_mut() {
object.remove("outputs");
object.remove("execution_count");
}
}
}
cell.get("id") cell.get("id")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.map(ToString::to_string) .map(ToString::to_string)
@@ -1357,7 +1449,7 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?; std::fs::write(&path, &updated_file).map_err(|error| error.to_string())?;
Ok(NotebookEditOutput { Ok(NotebookEditOutput {
new_source: input.new_source, new_source,
cell_id, cell_id,
cell_type: resolved_cell_type, cell_type: resolved_cell_type,
language, language,
@@ -1369,6 +1461,51 @@ fn execute_notebook_edit(input: NotebookEditInput) -> Result<NotebookEditOutput,
}) })
} }
fn require_notebook_source(
source: Option<String>,
edit_mode: NotebookEditMode,
) -> Result<String, String> {
match edit_mode {
NotebookEditMode::Delete => Ok(source.unwrap_or_default()),
NotebookEditMode::Insert | NotebookEditMode::Replace => source
.ok_or_else(|| String::from("new_source is required for insert and replace edits")),
}
}
fn build_notebook_cell(cell_id: &str, cell_type: NotebookCellType, source: &str) -> Value {
let mut cell = json!({
"cell_type": match cell_type {
NotebookCellType::Code => "code",
NotebookCellType::Markdown => "markdown",
},
"id": cell_id,
"metadata": {},
"source": source_lines(source),
});
if let Some(object) = cell.as_object_mut() {
match cell_type {
NotebookCellType::Code => {
object.insert(String::from("outputs"), json!([]));
object.insert(String::from("execution_count"), Value::Null);
}
NotebookCellType::Markdown => {}
}
}
cell
}
fn cell_kind(cell: &serde_json::Value) -> Option<NotebookCellType> {
cell.get("cell_type")
.and_then(serde_json::Value::as_str)
.map(|kind| {
if kind == "markdown" {
NotebookCellType::Markdown
} else {
NotebookCellType::Code
}
})
}
fn execute_sleep(input: SleepInput) -> SleepOutput { fn execute_sleep(input: SleepInput) -> SleepOutput {
std::thread::sleep(Duration::from_millis(input.duration_ms)); std::thread::sleep(Duration::from_millis(input.duration_ms));
SleepOutput { SleepOutput {
@@ -1551,7 +1688,7 @@ fn resolve_cell_index(
.position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id)) .position(|cell| cell.get("id").and_then(serde_json::Value::as_str) == Some(cell_id))
.ok_or_else(|| format!("Cell id not found: {cell_id}")) .ok_or_else(|| format!("Cell id not found: {cell_id}"))
} else { } else {
Ok(0) Ok(cells.len().saturating_sub(1))
} }
} }
@@ -1787,6 +1924,20 @@ mod tests {
serde_json::from_str(&selected).expect("valid json"); serde_json::from_str(&selected).expect("valid json");
assert_eq!(selected_output["matches"][0], "Agent"); assert_eq!(selected_output["matches"][0], "Agent");
assert_eq!(selected_output["matches"][1], "Skill"); assert_eq!(selected_output["matches"][1], "Skill");
let aliased = execute_tool("ToolSearch", &json!({"query": "AgentTool"}))
.expect("ToolSearch should support tool aliases");
let aliased_output: serde_json::Value = serde_json::from_str(&aliased).expect("valid json");
assert_eq!(aliased_output["matches"][0], "Agent");
assert_eq!(aliased_output["normalized_query"], "agent");
let selected_with_alias =
execute_tool("ToolSearch", &json!({"query": "select:AgentTool,Skill"}))
.expect("ToolSearch alias select should succeed");
let selected_with_alias_output: serde_json::Value =
serde_json::from_str(&selected_with_alias).expect("valid json");
assert_eq!(selected_with_alias_output["matches"][0], "Agent");
assert_eq!(selected_with_alias_output["matches"][1], "Skill");
} }
#[test] #[test]
@@ -1816,15 +1967,33 @@ mod tests {
assert_eq!(output["name"], "ship-audit"); assert_eq!(output["name"], "ship-audit");
assert_eq!(output["subagentType"], "Explore"); assert_eq!(output["subagentType"], "Explore");
assert_eq!(output["status"], "queued"); assert_eq!(output["status"], "queued");
assert!(output["createdAt"].as_str().is_some());
let manifest_file = output["manifestFile"].as_str().expect("manifest file");
let output_file = output["outputFile"].as_str().expect("output file"); let output_file = output["outputFile"].as_str().expect("output file");
let contents = std::fs::read_to_string(output_file).expect("agent file exists"); let contents = std::fs::read_to_string(output_file).expect("agent file exists");
let manifest_contents =
std::fs::read_to_string(manifest_file).expect("manifest file exists");
assert!(contents.contains("Audit the branch")); assert!(contents.contains("Audit the branch"));
assert!(contents.contains("Check tests and outstanding work.")); assert!(contents.contains("Check tests and outstanding work."));
assert!(manifest_contents.contains("\"subagentType\": \"Explore\""));
let normalized = execute_tool(
"Agent",
&json!({
"description": "Verify the branch",
"prompt": "Check tests.",
"subagent_type": "explorer"
}),
)
.expect("Agent should normalize built-in aliases");
let normalized_output: serde_json::Value =
serde_json::from_str(&normalized).expect("valid json");
assert_eq!(normalized_output["subagentType"], "Explore");
let _ = std::fs::remove_dir_all(dir); let _ = std::fs::remove_dir_all(dir);
} }
#[test] #[test]
fn notebook_edit_replaces_and_inserts_cells() { fn notebook_edit_replaces_inserts_and_deletes_cells() {
let path = std::env::temp_dir().join(format!( let path = std::env::temp_dir().join(format!(
"clawd-notebook-{}.ipynb", "clawd-notebook-{}.ipynb",
std::time::SystemTime::now() std::time::SystemTime::now()
@@ -1872,9 +2041,40 @@ mod tests {
.expect("NotebookEdit insert should succeed"); .expect("NotebookEdit insert should succeed");
let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json"); let inserted_output: serde_json::Value = serde_json::from_str(&inserted).expect("json");
assert_eq!(inserted_output["cell_type"], "markdown"); assert_eq!(inserted_output["cell_type"], "markdown");
let final_notebook = std::fs::read_to_string(&path).expect("read notebook"); let appended = execute_tool(
assert!(final_notebook.contains("print(2)")); "NotebookEdit",
assert!(final_notebook.contains("# heading")); &json!({
"notebook_path": path.display().to_string(),
"new_source": "print(3)\n",
"edit_mode": "insert"
}),
)
.expect("NotebookEdit append should succeed");
let appended_output: serde_json::Value = serde_json::from_str(&appended).expect("json");
assert_eq!(appended_output["cell_type"], "code");
let deleted = execute_tool(
"NotebookEdit",
&json!({
"notebook_path": path.display().to_string(),
"cell_id": "cell-a",
"edit_mode": "delete"
}),
)
.expect("NotebookEdit delete should succeed without new_source");
let deleted_output: serde_json::Value = serde_json::from_str(&deleted).expect("json");
assert!(deleted_output["cell_type"].is_null());
assert_eq!(deleted_output["new_source"], "");
let final_notebook: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).expect("read notebook"))
.expect("valid notebook json");
let cells = final_notebook["cells"].as_array().expect("cells array");
assert_eq!(cells.len(), 2);
assert_eq!(cells[0]["cell_type"], "markdown");
assert!(cells[0].get("outputs").is_none());
assert_eq!(cells[1]["cell_type"], "code");
assert_eq!(cells[1]["source"][0], "print(3)\n");
let _ = std::fs::remove_file(path); let _ = std::fs::remove_file(path);
} }