pub mod acp;
pub mod closure;
pub mod events;
pub mod internal;
#[cfg(unix)]
pub mod sandbox;
pub mod severity;
pub mod types;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::ToolDescription;
pub use types::{SubAgentCallInput, SubAgentCallOutput};
pub struct SubAgent {
pub description_call: ToolDescription,
note: String,
last_input: Mutex<Option<String>>,
last_session: Arc<Mutex<HashMap<String, String>>>,
}
impl Default for SubAgent {
fn default() -> Self {
Self::new()
}
}
impl SubAgent {
#[must_use]
pub fn new() -> Self {
Self {
description_call: Self::build_tool_description(""),
note: String::new(),
last_input: Mutex::new(None),
last_session: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn set_note(&mut self, note: impl Into<String>) {
self.note = note.into().trim().to_string();
self.description_call = Self::build_tool_description(&self.note);
}
#[allow(clippy::expect_used, clippy::format_collect)]
fn build_tool_description(note: &str) -> ToolDescription {
let installed = acp::detect_installed();
let note = if note.is_empty() {
String::new()
} else {
format!(" {note}")
};
let common = format!(
"Call a supported agent ACP harness with the given input message and \
return its output.{note}\n\
`input` is optional: if omitted, the last message sent to a \
sub-agent in this session is reused automatically, so a failed \
call can be retried without re-writing the prompt. If no \
sub-agent has been called yet, an error is returned."
);
let usage = "## When to use\n\
- Use `subagent_call` with `agent` (and optionally `input`) \
to delegate a task to another agent ACP harness.\n\
- Omit `input` to reuse the last message sent to a sub-agent.\n\
- Consecutive calls to the same agent resume that agent's \
most recent session by default, keeping its context (ideal \
for review/iteration follow-ups). Pass `continue_session: \
false` when the new task is unrelated and needs clean context.\n\
- Omit `agent` (or pass an empty string) to call the internal \
agent instead of an external ACP harness.\n\
- Set `run_in_background: true` to spawn the sub-agent without \
blocking: the call returns a `task_id` immediately and the final \
report is delivered later as an automated completion notification. \
Query progress with `subagent_status`. Keep background spawns \
focused: prefer 3–5 parallel sub-agents, and use the synchronous \
mode when you need the result before continuing.\n\
- Use `bash_run` for regular shell commands. \
These are separate tools with different purposes.";
let description = if installed.is_empty() {
format!(
"{common}\n\
## Supported agents\n\
(None detected — install one of the ACP-capable agents \
(gemini, goose, opencode, kilo, claude, codex) and restart cosh.)\n\
{usage}"
)
} else {
let agents_desc = installed
.iter()
.map(|name| {
let entry = acp::ACP_AGENTS
.iter()
.find(|a| a.name == *name)
.expect("installed agent must be in ACP_AGENTS");
let invocation = entry.invocation();
format!("- `{name}` → `{invocation}`\n")
})
.collect::<String>();
format!(
"{common}\n\
## Supported agents\n\
{agents_desc}\n\
{usage}"
)
};
let enum_values: Vec<serde_json::Value> = if installed.is_empty() {
acp::ACP_AGENTS
.iter()
.map(|a| serde_json::Value::String(a.name.to_string()))
.collect()
} else {
installed
.iter()
.map(|n| serde_json::Value::String(n.to_string()))
.collect()
};
serde_json::json!({
"name": "subagent_call",
"description": description,
"inputSchema": {
"type": "object",
"properties": {
"agent": {
"type": "string",
"description": "The agent ACP harness to call. Optional: if omitted (or empty), an internal agent with an empty context runs the task instead and returns only its final report.",
"enum": enum_values,
},
"input": {
"type": "string",
"description": "The message to send to the sub-agent as input. Optional: if omitted (or empty), the last message sent to a sub-agent in this session is reused automatically, so a failed call can be retried without re-writing the prompt. If no sub-agent has been called yet, an error is returned.",
},
"code_review": {
"type": "boolean",
"description": "Set to true when the task is a CODE REVIEW. The sub-agent's final report then starts with a `<!-- severity: green|yellow|red -->` header consumed by the client: it tints the sub-agent box (green = at most cosmetic details, yellow = minor issues / bad practice, red = something critical found). Ignored for non-review tasks. Optional, defaults to false.",
},
"continue_session": {
"type": "boolean",
"description": "Resume the agent's most recent session, keeping its context (default). Set false to start a brand-new session when the new task is unrelated and needs clean context.",
},
"run_in_background": {
"type": "boolean",
"description": "Set true to spawn the sub-agent WITHOUT blocking: the call returns a task_id immediately and the final report is delivered later as an automated completion notification. Query progress with the subagent_status tool. Omitted or false (default): the call blocks until the sub-agent finishes and returns its report directly.",
},
},
"required": [],
},
})
}
#[allow(clippy::unwrap_used)]
pub fn resolve_input(&self, input: Option<String>) -> Result<String, String> {
match input {
Some(text) => {
let text = text.trim().to_string();
if text.is_empty() {
self.stored_input()
} else {
*self.last_input.lock().unwrap() = Some(text.clone());
Ok(text)
}
}
None => self.stored_input(),
}
}
fn stored_input(&self) -> Result<String, String> {
self.last_input.lock().unwrap().clone().ok_or_else(|| {
"subagent_call was called without an 'input' argument, but there is \
no stored sub-agent message in this session yet. Provide an 'input' \
argument to send the first message."
.to_string()
})
}
#[allow(clippy::unwrap_used)]
pub fn store_session(&self, agent: &str, session_id: String) {
self.last_session
.lock()
.unwrap()
.insert(agent.to_string(), session_id);
}
#[must_use]
pub fn session_recorder(&self) -> Arc<Mutex<HashMap<String, String>>> {
Arc::clone(&self.last_session)
}
#[allow(clippy::unwrap_used)]
pub fn stored_session(&self, agent: &str) -> Option<String> {
self.last_session.lock().unwrap().get(agent).cloned()
}
#[allow(clippy::unwrap_used)]
pub fn resume_id(&self, agent: &str, continue_session: bool) -> Option<String> {
if continue_session {
self.stored_session(agent)
} else {
None
}
}
}
#[cfg(test)]
mod test;