mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
464ced28da
commit
d8e8ecf5a1
5 files changed
+199
-29
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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) —
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user