use std::{
path::{Path, PathBuf},
sync::Arc,
};
use rho_sdk::{
model::ToolSpec,
tool::{
OperationKind, Tool, ToolContext, ToolError, ToolErrorKind, ToolFuture, ToolInvocation,
ToolMetadata, ToolOutput, ToolSecurity,
},
ToolCallId,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use super::{
agent_metadata, agent_spec, agents_metadata,
host_orchestrator::{HostOrchestrator, HostRun},
parse_agent_args,
};
use crate::{
agent::{AdvertisedAgents, AdvertisedChange, AgentCatalog, AgentDefinition, AgentRuntimeSpec},
tools::{agent_output::AgentRunView, sdk_registry::AppToolSet},
};
pub(crate) fn install_host_delegation(
tools: &mut AppToolSet,
cwd: &Path,
max_output_bytes: usize,
) -> anyhow::Result<()> {
if !tools.contains("agent") {
return Ok(());
}
let orchestrator = Arc::new(HostOrchestrator::connect(tools)?);
let catalog = AgentCatalog::discover(cwd)?;
let agent = HostAgentTool::new(
orchestrator.clone(),
cwd,
&catalog,
max_output_bytes,
tools.checkpoint_tracker().clone(),
);
tools.replace_delegation_tools([
Arc::new(agent),
Arc::new(HostAgentsTool {
orchestrator,
max_output_bytes,
}),
]);
Ok(())
}
struct HostAgentTool {
orchestrator: Arc<HostOrchestrator>,
cwd: PathBuf,
advertised: AdvertisedAgents,
max_output_bytes: usize,
mutation_observer: Arc<dyn rho_tools::WorkspaceMutationObserver>,
}
impl HostAgentTool {
fn new(
orchestrator: Arc<HostOrchestrator>,
cwd: &Path,
catalog: &AgentCatalog,
max_output_bytes: usize,
mutation_observer: Arc<dyn rho_tools::WorkspaceMutationObserver>,
) -> Self {
let mut advertised = AdvertisedAgents::from_catalog(catalog);
for entry in catalog.iter() {
match &entry.definition.runtime {
AgentRuntimeSpec::Rho { .. } => {}
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => {
advertised.apply(&AdvertisedChange::Unavailable {
agent_id: entry.definition.id.to_string(),
});
}
}
}
Self {
orchestrator,
cwd: cwd.to_owned(),
advertised,
max_output_bytes,
mutation_observer,
}
}
async fn execute(
&self,
invocation: ToolInvocation,
context: ToolContext,
) -> Result<ToolOutput, ToolError> {
let args = parse_agent_args(invocation.arguments().clone())?;
let catalog = AgentCatalog::discover(&self.cwd).map_err(|error| {
ToolError::new(
ToolErrorKind::Execution,
format!("could not reload agent definitions: {error}"),
)
})?;
let definition = &catalog
.find(&args.agent_id)
.map_err(|error| ToolError::new(ToolErrorKind::InvalidArguments, error.to_string()))?
.definition;
self.launch(invocation.id().clone(), definition, &args.prompt, context)
.await
}
async fn launch(
&self,
invocation_id: ToolCallId,
definition: &AgentDefinition,
prompt: &str,
context: ToolContext,
) -> Result<ToolOutput, ToolError> {
match &definition.runtime {
AgentRuntimeSpec::Rho { .. } => {}
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => {
return Err(ToolError::new(
ToolErrorKind::InvalidArguments,
format!(
"{} runs on {}, which a host-managed Rho ACP child cannot host; \
call T3's delegate_task tool directly to start another provider",
definition.id,
definition.runtime.runtime()
),
));
}
}
self.mutation_observer
.mark_untracked_effect(rho_tools::UntrackedWorkspaceEffect::MutatingTool, "agent");
let run = self
.orchestrator
.delegate(invocation_id, definition, prompt, context)
.await?;
rho_tools::Rendered::new(
format!(
"agent {} ({}) started in background through the host",
run.task_id, run.agent_id
),
AgentRunView::started(run.task_id, run.agent_id),
)
.limit_data(self.max_output_bytes)?
.into_tool_output(agent_metadata())
}
}
impl Tool for HostAgentTool {
fn spec(&self) -> ToolSpec {
agent_spec(
&self.advertised,
" The host runs the child in the background and returns its task id immediately. The result arrives as your next turn; continue independent work or end your turn to wait. Never sleep or poll for results, and do not declare the task complete before required children finish. Parent cancellation and shutdown do not stop host-managed children; use agents stop explicitly.",
)
}
fn output_schema(&self) -> Option<Value> {
Some(rho_tools::output_schema::<AgentRunView>())
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([])
}
fn start_metadata(&self, _arguments: &Value) -> ToolMetadata {
agent_metadata()
}
fn call<'a>(&'a self, invocation: ToolInvocation, context: ToolContext) -> ToolFuture<'a> {
Box::pin(self.execute(invocation, context))
}
}
struct HostAgentsTool {
orchestrator: Arc<HostOrchestrator>,
max_output_bytes: usize,
}
#[derive(Serialize, schemars::JsonSchema)]
struct HostRunView {
task_id: String,
child_thread_id: Option<String>,
agent_id: String,
title: String,
}
impl From<&HostRun> for HostRunView {
fn from(run: &HostRun) -> Self {
Self {
task_id: run.task_id.clone(),
child_thread_id: run.child_thread_id.clone(),
agent_id: run.agent_id.clone(),
title: run.title.clone(),
}
}
}
#[derive(Serialize, schemars::JsonSchema)]
#[serde(tag = "action", rename_all = "snake_case")]
enum HostAgentsOutput {
List { runs: Vec<HostRunView> },
Status { task_id: String, host: Value },
Stop { task_id: String, host: Value },
}
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum HostAction {
List,
Status,
Stop,
Message,
}
#[derive(Deserialize)]
struct HostAgentsArgs {
action: HostAction,
id: Option<String>,
reason: Option<String>,
}
impl HostAgentsTool {
async fn execute(
&self,
invocation: ToolInvocation,
context: ToolContext,
) -> Result<ToolOutput, ToolError> {
let args: HostAgentsArgs = serde_json::from_value(invocation.arguments().clone())
.map_err(|error| ToolError::new(ToolErrorKind::InvalidArguments, error.to_string()))?;
let (task_id, output, data) = match args.action {
HostAction::List => {
let runs = self.orchestrator.runs();
let content = if runs.is_empty() {
"no host-managed agents".to_owned()
} else {
runs.iter()
.map(|run| {
format!(
"{} ({}) — {} (started {}s ago; host owns status)",
run.task_id,
run.agent_id,
run.title,
run.started.elapsed().as_secs()
)
})
.collect::<Vec<_>>()
.join("\n")
};
return rho_tools::Rendered::new(
content,
HostAgentsOutput::List {
runs: runs.iter().map(HostRunView::from).collect(),
},
)
.limit_data(self.max_output_bytes)?
.into_tool_output(agents_metadata());
}
HostAction::Message => {
return Err(ToolError::new(
ToolErrorKind::InvalidArguments,
"message is not available for host-managed children; stop and delegate again",
));
}
HostAction::Status => {
let task_id = required_id(args.id)?;
let output = self
.orchestrator
.status(invocation.id().clone(), &task_id, context)
.await?;
let data = HostAgentsOutput::Status {
task_id: task_id.clone(),
host: output.structured_content().cloned().unwrap_or(Value::Null),
};
(task_id, output, data)
}
HostAction::Stop => {
let task_id = required_id(args.id)?;
let output = self
.orchestrator
.cancel(
invocation.id().clone(),
&task_id,
args.reason.as_deref(),
context,
)
.await?;
let data = HostAgentsOutput::Stop {
task_id: task_id.clone(),
host: output.structured_content().cloned().unwrap_or(Value::Null),
};
(task_id, output, data)
}
};
let label = self
.orchestrator
.run(&task_id)
.map_or(task_id, |run| run.agent_id);
let rendered = rho_tools::Rendered::new(format!("{label}: {}", output.content()), data)
.limit_data(self.max_output_bytes)?
.into_tool_output(
output
.presentation()
.clone()
.operation(OperationKind::Other("agents".into())),
)?
.with_images(output.images().to_vec());
Ok(if output.is_failure() {
rendered.failed()
} else {
rendered
})
}
}
fn required_id(id: Option<String>) -> Result<String, ToolError> {
id.filter(|id| !id.is_empty()).ok_or_else(|| {
ToolError::new(
ToolErrorKind::InvalidArguments,
"this action requires a host task id",
)
})
}
impl Tool for HostAgentsTool {
fn spec(&self) -> ToolSpec {
ToolSpec {
name: "agents".into(),
description: "List locally launched host-managed children, inspect host status, or stop a child explicitly. Results arrive automatically as the host's next turn: end your turn to wait, never poll. Parent cancellation and session shutdown leave these children running. Messages are unavailable; stop and delegate again to change a task. The host owns durable history; this list resets when the session reloads.".into(),
input_schema: json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["list", "status", "stop"] },
"id": { "type": "string", "description": "Host task ID (required for status and stop)" },
"reason": { "type": "string", "description": "Optional reason for stopping the child" }
},
"required": ["action"],
"additionalProperties": false
}),
}
}
fn output_schema(&self) -> Option<Value> {
Some(rho_tools::output_schema::<HostAgentsOutput>())
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([])
}
fn start_metadata(&self, _arguments: &Value) -> ToolMetadata {
agents_metadata()
}
fn call<'a>(&'a self, invocation: ToolInvocation, context: ToolContext) -> ToolFuture<'a> {
Box::pin(self.execute(invocation, context))
}
}
#[cfg(test)]
#[path = "host_tools_tests.rs"]
mod tests;