tools: advertise only schema roles in the agent tool and make the speech tool provider-neutral (#5940, #5941)

The agent tool's description named worker/scout/builder/verifier/
consultant and said 'Prefer type=builder', while its own type enum only
accepts general/explore/planner/reviewer/implement/test/advisor/custom;
a strict gateway rejects the advertised value against the declared enum.
The description now uses the canonical names, and a guard test parses
every type=<token> and every role in the description and the type-field
text against FLEET_ROLE_SCHEMA_VALUES so the two cannot drift again.
Legacy aliases still parse at the deserialization boundary.

The speech tool told every model it ran on 'the configured Xiaomi MiMo
OpenAI-compatible API', shipped twice (speech + tts, byte-identical), and
was registered even with no client. It now describes the capability with
provider specifics behind a 'for the MiMo provider' clause, registers tts
as a hidden compat alias, and is skipped from the agent runtime surface
when there is no client. A guard test asserts no model-visible tool
description names a vendor.

cargo test -p codewhale-tui --lib -- tools::registry:: tools::speech::
agent_tool_description_names_only_schema_roles: 57 passed, 0 failed.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-06 02:36:30 -07:00
1 parent 464ced28da
commit d8e8ecf5a1
5 files changed
+199 -29

No files matched your search

+10 -4
View File
@@ -1048,7 +1048,8 @@ impl ToolRegistryBuilder {
self.with_tool(Arc::new(RevertTurnTool))
}
/// Include Xiaomi MiMo speech/TTS tools (`speech`, `tts`).
/// Include the speech/TTS tool: `speech` is model-visible, `tts` is a
/// hidden compat alias for saved-transcript replay (#5941).
#[must_use]
pub fn with_speech_tools(
self,
@@ -1061,7 +1062,7 @@ impl ToolRegistryBuilder {
client.clone(),
output_dir.clone(),
)))
.with_tool(Arc::new(SpeechTool::new("tts", client, output_dir)))
.with_tool(Arc::new(SpeechTool::alias("tts", client, output_dir)))
}
/// Include the canonical persistent RLM session tool.
@@ -1289,8 +1290,13 @@ impl ToolRegistryBuilder {
.with_review_tool(client.clone(), model.clone())
.with_rlm_tool(client.clone(), model.clone())
.with_harness_tool()
.with_fim_tool(client, model)
.with_speech_tools(speech_client, options.speech_output_dir.clone());
.with_fim_tool(client, model);
// No client means no speech provider to call: do not advertise a
// capability the session cannot deliver (#5941).
if speech_client.is_some() {
builder = builder.with_speech_tools(speech_client, options.speech_output_dir.clone());
}
if options.verify_tool_enabled {
builder = builder.with_verify_tool(verify_client, verify_model);
+72
View File
@@ -442,6 +442,78 @@ fn builder_registers_speech_alias_tools() {
assert!(registry.contains("speech"));
assert!(registry.contains("tts"));
// One capability, one catalog entry: the alias stays callable for replay
// but is not advertised (#5941).
let visible: Vec<String> = registry
.to_api_tools()
.into_iter()
.map(|tool| tool.name)
.collect();
assert!(visible.iter().any(|name| name == "speech"));
assert!(!visible.iter().any(|name| name == "tts"), "{visible:?}");
}
#[test]
fn agent_runtime_surface_skips_speech_without_a_client() {
use super::AgentToolSurfaceOptions;
use crate::worker_profile::ShellPolicy;
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let registry = ToolRegistryBuilder::new()
.with_agent_runtime_surface(
None,
"test-model".to_string(),
AgentToolSurfaceOptions::new(ShellPolicy::Full),
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
)
.build(ctx);
assert!(!registry.contains("speech"));
assert!(!registry.contains("tts"));
}
#[test]
fn model_visible_tool_descriptions_name_no_vendor() {
use super::AgentToolSurfaceOptions;
use crate::worker_profile::ShellPolicy;
let tmp = tempdir().expect("tempdir");
let ctx = ToolContext::new(tmp.path().to_path_buf());
let mut options = AgentToolSurfaceOptions::new(ShellPolicy::Full);
options.web_search_enabled = true;
let registry = ToolRegistryBuilder::new()
.with_agent_runtime_surface(
None,
"test-model".to_string(),
options,
crate::tools::todo::new_shared_todo_list(),
crate::tools::plan::new_shared_plan_state(),
)
.with_speech_tools(None, None)
.build(ctx);
let vendors = [
"xiaomi",
"mimo",
"claude",
"anthropic",
"openai",
"gpt-",
"deepseek",
"gemini",
"kimi",
"qwen",
"grok",
"mistral",
];
for tool in registry.to_api_tools() {
let description = tool.description.to_ascii_lowercase();
for vendor in vendors {
assert!(
!description.contains(vendor),
"tool {} names a vendor ({vendor}) in its model-facing description",
tool.name
);
}
}
}
#[test]
+31 -7
View File
@@ -43,6 +43,9 @@ pub struct SpeechTool {
name: &'static str,
client: Option<DeepSeekClient>,
output_dir: Option<PathBuf>,
/// The canonical `speech` entry is model-visible; `tts` is a hidden
/// compat alias so one capability costs one catalog entry (#5941).
visible: bool,
}
impl SpeechTool {
@@ -56,6 +59,23 @@ impl SpeechTool {
name,
client,
output_dir,
visible: true,
}
}
/// A hidden alias for saved-transcript replay: same behaviour, not
/// advertised to the model.
#[must_use]
pub fn alias(
name: &'static str,
client: Option<DeepSeekClient>,
output_dir: Option<PathBuf>,
) -> Self {
Self {
name,
client,
output_dir,
visible: false,
}
}
}
@@ -66,8 +86,12 @@ impl ToolSpec for SpeechTool {
self.name
}
fn model_visible(&self) -> bool {
self.visible
}
fn description(&self) -> &str {
"Generate speech/audio directly through the configured Xiaomi MiMo OpenAI-compatible API. Use this when the user asks for speech, TTS, narration, read-aloud, voice design, or voice cloning."
"Generate speech/audio through the configured speech (TTS) provider. Use this when the user asks for speech, TTS, narration, read-aloud, voice design, or voice cloning. The provider decides which models and voices exist; omit model to take its default."
}
fn input_schema(&self) -> Value {
@@ -76,7 +100,7 @@ impl ToolSpec for SpeechTool {
"properties": {
"text": {
"type": "string",
"description": "Text to synthesize. This is sent as the assistant message and is the spoken content; MiMo TTS style/audio tags may be included here."
"description": "Text to synthesize; the spoken content. Provider style/audio tags may be included when the configured provider supports them."
},
"output": {
"type": "string",
@@ -88,12 +112,12 @@ impl ToolSpec for SpeechTool {
},
"model": {
"type": "string",
"description": "TTS model. Defaults to mimo-v2.5-tts, or infers voice-design/voice-clone models from voice_prompt/clone_voice.",
"description": "TTS model. Omit to take the configured provider's default, or its voice-design/voice-clone model when voice_prompt/clone_voice is set (for the MiMo provider: mimo-v2.5-tts and variants).",
"enum": SPEECH_MODEL_EXAMPLES
},
"voice": {
"type": "string",
"description": "Built-in voice ID (for example mimo_default, 冰糖, 茉莉, 苏打, 白桦, Mia, Chloe, Milo, Dean) or a data:audio/...;base64,... URI for voice clone."
"description": "Built-in voice ID from the configured provider (MiMo examples: mimo_default, 冰糖, 茉莉, 苏打, 白桦, Mia, Chloe, Milo, Dean) or a data:audio/...;base64,... URI for voice clone."
},
"instruction": {
"type": "string",
@@ -101,15 +125,15 @@ impl ToolSpec for SpeechTool {
},
"voice_prompt": {
"type": "string",
"description": "Voice design prompt. When model is omitted this uses mimo-v2.5-tts-voicedesign."
"description": "Voice design prompt. When model is omitted the provider's voice-design model is used (MiMo: mimo-v2.5-tts-voicedesign)."
},
"clone_voice": {
"type": "string",
"description": "Path to a .mp3 or .wav voice sample for cloning. When model is omitted this uses mimo-v2.5-tts-voiceclone."
"description": "Path to a .mp3 or .wav voice sample for cloning. When model is omitted the provider's voice-clone model is used (MiMo: mimo-v2.5-tts-voiceclone)."
},
"format": {
"type": "string",
"description": "Requested audio format. Default: wav. MiMo-V2.5-TTS documentation examples use wav and pcm16; mp3 is accepted when the API returns it.",
"description": "Requested audio format. Default: wav. Providers commonly document wav and pcm16; mp3 is accepted when the API returns it.",
"enum": SUPPORTED_SPEECH_FORMATS
},
"stream": {
+26 -18
View File
@@ -8385,6 +8385,31 @@ fn start_requests_read_only_role(input: &Value) -> bool {
)
}
/// The `agent` tool's model-facing description. Role names here must be the
/// canonical `FLEET_ROLE_SCHEMA_VALUES` the `type` enum accepts: the legacy
/// aliases (worker, scout, builder, verifier, consultant) still parse at the
/// deserialization boundary but a strict gateway rejects them against the
/// declared enum (#5940). `agent_tool_description_names_only_schema_roles`
/// pins this.
const AGENT_TOOL_DESCRIPTION: &str = concat!(
"Start with action=start and prompt; returns a turn-owned agent_id immediately. Read-only roles need no extra fields. Set detached=true only for work that must remain independently observable after the turn. ",
"Use multiple starts for independent parallel tasks. ",
"type selects the Fleet role: general (full tool access for multi-step tasks), explore (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), implement (lands focused code changes), test (runs tests and reports evidence), advisor (read-only design counsel), or custom (allowed_tools on the parent's posture). ",
"profile runs the child as a named Fleet role — pass a profile only when the task needs a different role than type selects. Without a profile the child inherits the parent's model; per-call model or thinking overrides are not part of this surface. ",
"Use action=roster to inspect the Fleet roles and their descriptions before choosing a type or profile. ",
"Child run budgets (model turns, wall time) come from Fleet role defaults and operator [subagents] config, not per-call fields. ",
"worktree=true gives the child an isolated git worktree — use it whenever parallel writers must not collide with the parent checkout. ",
"A write-capable child defaults write scope to the parent workspace; narrow it with write_roots (repo-relative directory trees) so parallel children claim disjoint scope. ",
"Prefer type=implement for write work and type=test (or the Run tool with action=\"verifiers\") after writes settle — dispatch is not completion. ",
"Coordinate through this same tool: action=message queues a note without waking the child; action=followup delivers queued notes and wakes a running child for its next user-provenance turn; action=interrupt stops the current child turn while preserving its checkpoint; action=wait blocks without changing child state, and until=\"all\" joins a whole fan-out in one call. ",
"action=claim widens your own enforced write scope: pass write_roots (and optionally exact_files, coordination_contracts) before mutating anything a fail-closed write refusal named. It records a durable claim receipt and fails on contention with a peer claim; it never touches another agent's scope. ",
"action=release clears write claims whose owner is no longer running — the fix a write-scope contention or blocking-peers refusal names. Pass agent_id for one, omit it to sweep; a live claim is never removed. ",
"Action contract: start requires prompt; message/followup require a target and message; peek/interrupt/cancel require a target; claim requires at least one scope entry; roster, status, and wait are unscoped. ",
"This is the whole model-facing sub-agent surface; there is no second transport. ",
"In Operate, use detached=true only for independent or long work that must outlive the active turn; a write-capable root start defaults write scope to the parent workspace unless narrowed with write_roots; arbitrary shell remains gated. ",
"Legacy action=status|peek|cancel remain for compatibility."
);
#[async_trait]
impl ToolSpec for AgentTool {
fn name(&self) -> &'static str {
@@ -8392,24 +8417,7 @@ impl ToolSpec for AgentTool {
}
fn description(&self) -> &'static str {
concat!(
"Start with action=start and prompt; returns a turn-owned agent_id immediately. Read-only roles need no extra fields. Set detached=true only for work that must remain independently observable after the turn. ",
"Use multiple starts for independent parallel tasks. ",
"type selects the Fleet role: worker (full tool access), scout (fast read-only exploration), planner (grounded strategy, read-only probes), reviewer (reads and grades code), builder (lands focused code changes), verifier (runs tests and reports evidence), consultant (read-only design counsel), or custom (allowed_tools on the parent's posture). ",
"profile runs the child as a named Fleet role — pass a profile only when the task needs a different role than type selects. Without a profile the child inherits the parent's model; per-call model or thinking overrides are not part of this surface. ",
"Use action=roster to inspect the Fleet roles and their descriptions before choosing a type or profile. ",
"Child run budgets (model turns, wall time) come from Fleet role defaults and operator [subagents] config, not per-call fields. ",
"worktree=true gives the child an isolated git worktree — use it whenever parallel writers must not collide with the parent checkout. ",
"A write-capable child defaults write scope to the parent workspace; narrow it with write_roots (repo-relative directory trees) so parallel children claim disjoint scope. ",
"Prefer type=builder for write work and type=verifier (or the Run tool with action=\"verifiers\") after writes settle — dispatch is not completion. ",
"Coordinate through this same tool: action=message queues a note without waking the child; action=followup delivers queued notes and wakes a running child for its next user-provenance turn; action=interrupt stops the current child turn while preserving its checkpoint; action=wait blocks without changing child state, and until=\"all\" joins a whole fan-out in one call. ",
"action=claim widens your own enforced write scope: pass write_roots (and optionally exact_files, coordination_contracts) before mutating anything a fail-closed write refusal named. It records a durable claim receipt and fails on contention with a peer claim; it never touches another agent's scope. ",
"action=release clears write claims whose owner is no longer running — the fix a write-scope contention or blocking-peers refusal names. Pass agent_id for one, omit it to sweep; a live claim is never removed. ",
"Action contract: start requires prompt; message/followup require a target and message; peek/interrupt/cancel require a target; claim requires at least one scope entry; roster, status, and wait are unscoped. ",
"This is the whole model-facing sub-agent surface; there is no second transport. ",
"In Operate, use detached=true only for independent or long work that must outlive the active turn; a write-capable root start defaults write scope to the parent workspace unless narrowed with write_roots; arbitrary shell remains gated. ",
"Legacy action=status|peek|cancel remain for compatibility."
)
AGENT_TOOL_DESCRIPTION
}
/// Advertised `agent` schema: exactly 12 fields (#5324, #5123) —
+60
View File
@@ -20850,3 +20850,63 @@ async fn agent_claim_is_withheld_from_a_role_with_no_write_authority() {
.to_string();
assert!(refusal.contains("no write authority to widen"), "{refusal}");
}
#[test]
fn agent_tool_description_names_only_schema_roles() {
// Every `type=<token>` and every role in the "type selects the Fleet role:"
// sentence must be a value the `type` enum accepts; the legacy aliases
// parse at the deserialization boundary but a strict gateway rejects
// them against the declared enum (#5940).
let schema: std::collections::HashSet<&str> = crate::fleet::role::FLEET_ROLE_SCHEMA_VALUES
.into_iter()
.collect();
let texts = [
super::AGENT_TOOL_DESCRIPTION,
super::SUBAGENT_TYPE_DESCRIPTION,
];
let mut seen = 0;
for text in texts {
for (index, _) in text.match_indices("type=") {
let token: String = text[index + "type=".len()..]
.chars()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
.collect();
assert!(
schema.contains(token.as_str()),
"type={token} is not a schema role"
);
seen += 1;
}
if let Some(start) = text.find("type selects the Fleet role:") {
let sentence = &text[start..];
let sentence = &sentence[..sentence.find(". ").unwrap_or(sentence.len())];
for (index, _) in sentence.match_indices(" (") {
let role: String = sentence[..index]
.chars()
.rev()
.take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
assert!(
schema.contains(role.as_str()),
"role {role} is not a schema role"
);
seen += 1;
}
}
}
assert!(
seen >= 10,
"the guard parsed too little of the description: {seen}"
);
for legacy in ["worker", "scout", "builder", "verifier", "consultant"] {
for text in texts {
assert!(
!text.contains(&format!("type={legacy}")) && !text.contains(&format!("{legacy} (")),
"legacy alias {legacy} is advertised in a model-facing description"
);
}
}
}