merge: tool alias deprecation metadata (fixes #72)

This commit is contained in:
Hunter Bown
2026-04-26 12:55:17 -05:00
3 changed files with 194 additions and 3 deletions
+86 -3
View File
@@ -52,11 +52,66 @@ const MAX_CSV_MAX_RUNTIME_SECONDS: u64 = 86_400;
const VALID_SUBAGENT_TYPES: &str =
"general, explore, plan, review, custom, worker, explorer, awaiter, default";
/// Removal version for deprecated tool aliases.
const DEPRECATION_REMOVAL_VERSION: &str = "0.8.0";
static AGENT_JOB_REPORTS: OnceLock<StdMutex<HashMap<String, HashMap<String, AgentJobReport>>>> =
OnceLock::new();
static AGENT_JOB_ASSIGNMENTS: OnceLock<StdMutex<HashMap<String, HashMap<String, String>>>> =
OnceLock::new();
// === Deprecation helpers ===
/// Wrap a `ToolResult` with a `_deprecation` block in its metadata.
///
/// Applied exclusively on alias paths (not on canonical tool names) so the
/// model can detect and migrate away from the old name before removal in
/// v`DEPRECATION_REMOVAL_VERSION`.
///
/// The `_deprecation` key is merged into any existing metadata so other
/// metadata (e.g. `status`, `timed_out`) is preserved unchanged.
fn wrap_with_deprecation_notice(
mut result: ToolResult,
this_tool: &str,
use_instead: &str,
) -> ToolResult {
tracing::warn!(
"Deprecated tool '{}' invoked — use '{}' instead (removal: v{})",
this_tool,
use_instead,
DEPRECATION_REMOVAL_VERSION,
);
let notice = json!({
"_deprecation": {
"this_tool": this_tool,
"use_instead": use_instead,
"removed_in": DEPRECATION_REMOVAL_VERSION,
"message": format!(
"Tool '{}' is deprecated; switch to '{}' before v{}.",
this_tool, use_instead, DEPRECATION_REMOVAL_VERSION
)
}
});
result.metadata = Some(match result.metadata.take() {
Some(Value::Object(mut map)) => {
if let Value::Object(notice_map) = notice {
map.extend(notice_map);
}
Value::Object(map)
}
Some(other) => {
// Existing metadata was not an object — keep it as-is and add
// the deprecation notice as a sibling under a wrapper.
json!({ "_deprecation": notice["_deprecation"].clone(), "_original_metadata": other })
}
None => notice,
});
result
}
// === Types ===
/// Assignment metadata for sub-agent orchestration.
@@ -1113,6 +1168,11 @@ impl ToolSpec for AgentSpawnTool {
tool_result.metadata = Some(json!({ "status": "Running" }));
}
}
// Annotate alias invocations with a deprecation notice so the model
// can migrate to the canonical name before removal in v0.8.0.
if self.name == "spawn_agent" {
tool_result = wrap_with_deprecation_notice(tool_result, "spawn_agent", "agent_spawn");
}
Ok(tool_result)
}
}
@@ -1329,7 +1389,13 @@ impl ToolSpec for AgentCloseTool {
let result = manager
.cancel(agent_id)
.map_err(|e| ToolError::execution_failed(format!("Failed to close sub-agent: {e}")))?;
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))
let tool_result =
ToolResult::json(&result).map_err(|e| ToolError::execution_failed(e.to_string()))?;
Ok(wrap_with_deprecation_notice(
tool_result,
"close_agent",
"agent_cancel",
))
}
}
@@ -1520,7 +1586,19 @@ impl ToolSpec for AgentSendInputTool {
.get_result(agent_id)
.map_err(|e| ToolError::execution_failed(e.to_string()))?;
ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string()))
let tool_result =
ToolResult::json(&snapshot).map_err(|e| ToolError::execution_failed(e.to_string()))?;
// Annotate the alias name "send_input" with a deprecation notice;
// the canonical name "agent_send_input" passes through unchanged.
if self.name == "send_input" {
Ok(wrap_with_deprecation_notice(
tool_result,
"send_input",
"agent_send_input",
))
} else {
Ok(tool_result)
}
}
}
@@ -1853,7 +1931,12 @@ impl ToolSpec for DelegateToAgentTool {
async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
let spawn_tool = AgentSpawnTool::new(self.manager.clone(), self.runtime.clone());
spawn_tool.execute(input, context).await
let result = spawn_tool.execute(input, context).await?;
Ok(wrap_with_deprecation_notice(
result,
"delegate_to_agent",
"agent_spawn",
))
}
}
+77
View File
@@ -631,3 +631,80 @@ fn test_interrupted_status_name_and_summary() {
assert_eq!(subagent_status_name(&snapshot.status), "interrupted");
assert!(summarize_subagent_result(&snapshot).contains(SUBAGENT_RESTART_REASON));
}
// === Deprecation notice tests ===
/// Helper: build a plain ToolResult with a JSON payload.
fn make_plain_result(payload: serde_json::Value) -> crate::tools::spec::ToolResult {
crate::tools::spec::ToolResult::json(&payload).expect("json result")
}
#[test]
fn test_wrap_with_deprecation_notice_adds_deprecation_block() {
let result = make_plain_result(json!({"agent_id": "abc"}));
let wrapped = wrap_with_deprecation_notice(result, "spawn_agent", "agent_spawn");
let meta = wrapped.metadata.expect("metadata should be present");
let dep = &meta["_deprecation"];
assert_eq!(dep["this_tool"], "spawn_agent");
assert_eq!(dep["use_instead"], "agent_spawn");
assert_eq!(dep["removed_in"], DEPRECATION_REMOVAL_VERSION);
assert!(
dep["message"]
.as_str()
.unwrap_or("")
.contains("spawn_agent")
);
}
#[test]
fn test_wrap_with_deprecation_notice_preserves_existing_metadata() {
let result = make_plain_result(json!({"agent_id": "abc"}))
.with_metadata(json!({"status": "Running", "snapshot": {}}));
let wrapped = wrap_with_deprecation_notice(result, "close_agent", "agent_cancel");
let meta = wrapped.metadata.expect("metadata should be present");
// Existing metadata key must survive.
assert_eq!(meta["status"], "Running");
// Deprecation block must be present alongside.
assert_eq!(meta["_deprecation"]["this_tool"], "close_agent");
assert_eq!(meta["_deprecation"]["use_instead"], "agent_cancel");
}
#[test]
fn test_canonical_agent_send_input_has_no_deprecation() {
let manager = Arc::new(Mutex::new(SubAgentManager::new(PathBuf::from("."), 1)));
// The canonical name "agent_send_input" must NOT receive a deprecation notice.
// We verify this by inspecting the tool's name — the deprecation branch
// only fires when name == "send_input".
let tool = AgentSendInputTool::new(manager.clone(), "agent_send_input");
assert_eq!(tool.name(), "agent_send_input");
let alias = AgentSendInputTool::new(manager, "send_input");
assert_eq!(alias.name(), "send_input");
}
#[test]
fn test_wrap_with_deprecation_notice_all_alias_mappings() {
let cases = [
("spawn_agent", "agent_spawn"),
("delegate_to_agent", "agent_spawn"),
("close_agent", "agent_cancel"),
("send_input", "agent_send_input"),
];
for (alias, canonical) in cases {
let result = make_plain_result(json!({"ok": true}));
let wrapped = wrap_with_deprecation_notice(result, alias, canonical);
let meta = wrapped.metadata.expect("metadata for alias {alias}");
assert_eq!(meta["_deprecation"]["this_tool"], alias, "alias={alias}");
assert_eq!(
meta["_deprecation"]["use_instead"], canonical,
"alias={alias}"
);
assert_eq!(
meta["_deprecation"]["removed_in"], DEPRECATION_REMOVAL_VERSION,
"alias={alias}"
);
}
}
+31
View File
@@ -79,6 +79,37 @@ no longer pollute the model's tool list):
- `close_agent` → use `agent_cancel`.
- `assign_agent` → use `agent_assign`.
## Deprecation schedule (v0.6.2 → v0.8.0)
The alias tools below still execute successfully but now attach a
`_deprecation` block to every result they return. Models should migrate to
the canonical name before v0.8.0, when the aliases will be removed.
| Deprecated alias | Canonical name | Warning since | Removal |
|---|---|---|---|
| `spawn_agent` | `agent_spawn` | v0.6.2 | v0.8.0 |
| `delegate_to_agent` | `agent_spawn` | v0.6.2 | v0.8.0 |
| `close_agent` | `agent_cancel` | v0.6.2 | v0.8.0 |
| `send_input` | `agent_send_input` | v0.6.2 | v0.8.0 |
The `_deprecation` block shape:
```json
{
"_deprecation": {
"this_tool": "spawn_agent",
"use_instead": "agent_spawn",
"removed_in": "0.8.0",
"message": "Tool 'spawn_agent' is deprecated; switch to 'agent_spawn' before v0.8.0."
}
}
```
This block is merged into the tool result's `metadata` object alongside any
other metadata keys (e.g. `status`, `timed_out`) so it does not displace
existing metadata. A one-line deprecation warning is also emitted to the
audit log at `tracing::warn` level every time an alias is invoked.
## Why we don't ship a single `bash` tool
Single-`bash` agents (Claude Code's design) are powerful but hand the model