use anyhow::Result;
use crate::entities::profile::ToolId;
use super::{Tool, ToolContext, ToolOutcome};
pub const CALL_SUBAGENT_ID: &str = "call_subagent";
pub const START_SUBAGENT_ID: &str = "start_subagent";
pub fn withheld_from_subagent(id: &str) -> bool {
id == CALL_SUBAGENT_ID
|| id == START_SUBAGENT_ID
|| id == super::dialogue::RUN_DIALOGUE_ID
|| id == super::dialogue::START_DIALOGUE_ID
|| id == super::history::HISTORY_READ_ID
|| id == super::history::HISTORY_SEARCH_ID
|| super::self_model::is_self_model_tool(id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubagentArgs {
pub name: Option<String>,
pub system_message: String,
pub message: String,
}
impl SubagentArgs {
pub fn parse(args: &serde_json::Value, loc: &crate::shared::i18n::Locale) -> Result<Self> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let system_message = args
.get("system_message")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let message = args
.get("message")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(loc.t("tool.call_subagent.err.message_empty").to_string())
})?
.to_string();
Ok(Self {
name,
system_message,
message,
})
}
pub fn initial_title(&self) -> String {
self.name
.as_deref()
.and_then(crate::shared::title::sanitize_title)
.or_else(|| {
self.message
.lines()
.find(|l| !l.trim().is_empty())
.and_then(crate::shared::title::sanitize_title)
})
.unwrap_or_else(|| CALL_SUBAGENT_ID.to_string())
}
}
pub struct CallSubagent {
pub parallel: u32,
}
#[async_trait::async_trait]
impl Tool for CallSubagent {
fn id(&self) -> ToolId {
CALL_SUBAGENT_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Subagent
}
fn ui_label(&self) -> &'static str {
"subagent request"
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
let base = loc.t("tool.call_subagent.desc");
if self.parallel > 1 {
format!(
"{base} {}",
loc.tf(
"tool.call_subagent.desc.parallel",
&[("n", &self.parallel.to_string())]
)
)
} else {
base.into()
}
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
subagent_parameters(loc)
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
SubagentArgs::parse(&args, ctx.loc)?;
Ok(ToolOutcome::text(
ctx.loc.t("tool.call_subagent.result.loop_only"),
))
}
}
fn subagent_parameters(loc: &crate::shared::i18n::Locale) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": loc.t("tool.call_subagent.param.name")
},
"system_message": {
"type": "string",
"description": loc.t("tool.call_subagent.param.system_message")
},
"message": {
"type": "string",
"description": loc.t("tool.call_subagent.param.message")
}
},
"required": ["system_message", "message"]
})
}
pub struct StartSubagent;
#[async_trait::async_trait]
impl Tool for StartSubagent {
fn id(&self) -> ToolId {
START_SUBAGENT_ID.into()
}
fn group(&self) -> crate::features::tools::meta::ToolGroup {
crate::features::tools::meta::ToolGroup::Subagent
}
fn ui_label(&self) -> &'static str {
"background subagent"
}
fn gate(&self) -> Option<crate::features::tools::meta::ToolGate> {
Some(crate::features::tools::meta::ToolGate::Background)
}
fn description(&self, loc: &crate::shared::i18n::Locale) -> String {
loc.t("tool.start_subagent.desc").into()
}
fn parameters(&self, loc: &crate::shared::i18n::Locale) -> serde_json::Value {
subagent_parameters(loc)
}
async fn invoke(&self, ctx: &ToolContext, args: serde_json::Value) -> Result<ToolOutcome> {
SubagentArgs::parse(&args, ctx.loc)?;
Ok(ToolOutcome::text(
ctx.loc.t("tool.call_subagent.result.loop_only"),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::shared::i18n::{Lang, locale};
fn en() -> &'static crate::shared::i18n::Locale {
locale(Lang::En)
}
#[test]
fn args_parse_trims_and_reads_the_optional_name() {
let a = SubagentArgs::parse(
&serde_json::json!({"name": " Critic ", "system_message": " be harsh ", "message": " rate X "}),
en(),
)
.unwrap();
assert_eq!(a.name.as_deref(), Some("Critic"));
assert_eq!(a.system_message, "be harsh");
assert_eq!(a.message, "rate X");
let b = SubagentArgs::parse(&serde_json::json!({"message": "x"}), en()).unwrap();
assert_eq!(b.name, None);
assert_eq!(b.system_message, "");
}
#[test]
fn args_reject_an_empty_message() {
assert!(
SubagentArgs::parse(
&serde_json::json!({"system_message": "x", "message": " "}),
en()
)
.is_err()
);
assert!(SubagentArgs::parse(&serde_json::json!({"system_message": "x"}), en()).is_err());
}
#[test]
fn initial_title_prefers_the_name_then_the_first_line() {
let named = SubagentArgs {
name: Some("Critic".into()),
system_message: String::new(),
message: "rate X\nin detail".into(),
};
assert_eq!(named.initial_title(), "Critic");
let unnamed = SubagentArgs {
name: None,
..named
};
assert_eq!(unnamed.initial_title(), "rate X");
}
#[tokio::test]
async fn invoke_outside_the_loop_refuses_without_running() {
let (_dir, _storage, ctx) = super::super::testkit::ctx_with_storage(uuid::Uuid::new_v4());
let out = CallSubagent { parallel: 1 }
.invoke(
&ctx,
serde_json::json!({"system_message": "x", "message": "y"}),
)
.await
.unwrap();
assert_eq!(out.result, ctx.loc.t("tool.call_subagent.result.loop_only"));
assert!(out.effects.is_empty());
assert!(
CallSubagent { parallel: 1 }
.invoke(
&ctx,
serde_json::json!({"system_message": "x", "message": " "})
)
.await
.is_err()
);
}
#[test]
fn the_description_names_parallel_delegation_only_above_one() {
let base = CallSubagent { parallel: 1 }.description(en());
assert_eq!(base, en().t("tool.call_subagent.desc"));
let three = CallSubagent { parallel: 3 }.description(en());
assert!(three.starts_with(&base) && three.contains('3'), "{three}");
assert!(three.len() > base.len());
let ru = locale(Lang::Ru);
assert_eq!(
CallSubagent { parallel: 1 }.description(ru),
ru.t("tool.call_subagent.desc")
);
assert!(CallSubagent { parallel: 2 }.description(ru).contains('2'));
}
#[test]
fn start_subagent_is_the_twin_with_its_own_gate() {
use crate::features::tools::meta::ToolGate;
assert_eq!(StartSubagent.id(), START_SUBAGENT_ID);
assert_eq!(
StartSubagent.parameters(en()),
CallSubagent { parallel: 1 }.parameters(en())
);
assert_eq!(
StartSubagent.description(en()),
en().t("tool.start_subagent.desc")
);
assert_eq!(StartSubagent.gate(), Some(ToolGate::Background));
assert!(withheld_from_subagent(START_SUBAGENT_ID));
assert!(!StartSubagent.danger());
}
}