feat(tools): mark alias tools with deprecation metadata
Add `wrap_with_deprecation_notice` helper in the subagent module that merges a `_deprecation` block into a ToolResult's metadata. Applied exclusively on alias invocations: - `spawn_agent` → use `agent_spawn` (removed in v0.8.0) - `delegate_to_agent` → use `agent_spawn` (removed in v0.8.0) - `close_agent` → use `agent_cancel` (removed in v0.8.0) - `send_input` → use `agent_send_input` (removed in v0.8.0) Canonical names are unaffected. Each alias invocation also emits a `tracing::warn` so the deprecation appears in audit logs. Documents the deprecation schedule in `docs/TOOL_SURFACE.md`. Four unit tests verify the notice shape and that canonical tools stay clean. Refs #72 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user