use std::{path::Path, sync::Arc};
use rho_sdk::{
tool::{
OperationKind, PreparedToolInvocation, Tool as SdkTool, ToolContext as SdkToolContext,
ToolError as SdkToolError, ToolErrorKind, ToolFuture, ToolInvocation, ToolInvocationSource,
ToolMetadata, ToolOutput, ToolPreparationContext, ToolPrepareFuture, ToolResource,
ToolResourceAccess, ToolSecurity,
},
CapabilityKind, CapabilityRequest, CapabilitySource, HostChoice, HostInputRequest,
HostQuestion, SelectionMode,
};
use rho_tools::{
sdk_support::required_string,
tool::{truncate, Tool as _},
};
pub(super) fn skill_bundle(max_output_bytes: usize) -> super::sdk_registry::StaticToolBundle {
super::sdk_registry::StaticToolBundle::new(vec![Arc::new(SdkSkillTool::new(max_output_bytes))])
}
pub(super) fn questionnaire_bundle() -> super::sdk_registry::StaticToolBundle {
super::sdk_registry::StaticToolBundle::new(vec![Arc::new(QuestionnaireTool)])
}
pub(crate) fn message_parent_bundle(
poster: Arc<dyn crate::app::subagent_messaging::NoticePoster>,
) -> super::sdk_registry::StaticToolBundle {
super::sdk_registry::StaticToolBundle::new(vec![Arc::new(MessageParentTool { poster })])
}
impl SdkSkillTool {
pub(super) fn new(max_output_bytes: usize) -> Self {
Self { max_output_bytes }
}
fn prepare_fs_skill(
&self,
name: &str,
source_display: String,
requested: &Path,
skill_directory: &Path,
context: &ToolPreparationContext,
) -> Result<PreparedToolInvocation<'_>, SdkToolError> {
let workspace = preparation_workspace(context)?;
let skill_workspace = workspace
.clone()
.with_granted_root(skill_directory)
.map_err(|error| SdkToolError::new(ToolErrorKind::Execution, error.to_string()))?;
let resolved = skill_workspace
.resolve_for_read(requested)
.map_err(|error| SdkToolError::new(ToolErrorKind::Execution, error.to_string()))?;
let capability = CapabilityRequest::skill(
name,
Some(resolved.path().to_path_buf()),
CapabilitySource::built_in_tool("skill"),
);
let access = ToolResourceAccess::shared(ToolResource::workspace_path(resolved.path()));
let directory_display = crate::paths::display(skill_directory);
let max_output_bytes = self.max_output_bytes;
let name = name.to_string();
let metadata = ToolMetadata::new().operation(OperationKind::Read);
Ok(PreparedToolInvocation::resource_aware(
[access],
[capability],
metadata,
move |_context| {
Box::pin(async move {
skill_workspace.revalidate(&resolved).map_err(|error| {
SdkToolError::new(ToolErrorKind::PolicyDenied, error.to_string())
})?;
let contents =
tokio::fs::read_to_string(resolved.path())
.await
.map_err(|error| {
SdkToolError::new(ToolErrorKind::Execution, error.to_string())
})?;
let content = format!(
"Loaded skill: {name}\nSource: {source_display}\nReferences are relative to {directory_display}.\n\n{contents}"
);
Ok(ToolOutput::text(truncate(content, max_output_bytes)))
})
},
))
}
}
pub(super) struct SdkSkillTool {
max_output_bytes: usize,
}
impl SdkTool for SdkSkillTool {
fn spec(&self) -> rho_sdk::model::ToolSpec {
super::skill::Skill.spec()
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([CapabilityKind::Skill])
}
fn prepare<'a>(
&'a self,
invocation: ToolInvocation,
context: ToolPreparationContext,
) -> ToolPrepareFuture<'a> {
Box::pin(async move {
let invocation_source = invocation.source();
let name = required_string(invocation.arguments(), "name")?.to_string();
if !valid_skill_name(&name) {
return Err(SdkToolError::new(
ToolErrorKind::InvalidArguments,
"skill name must contain only ASCII letters, digits, '-' or '_'",
));
}
let skill = match crate::skills::find_builtin(&name) {
Some(skill) => skill,
None => {
let workspace = preparation_workspace(&context)?;
crate::skills::discover(workspace.root())
.into_iter()
.find(|skill| skill.name == name)
.ok_or_else(|| {
SdkToolError::new(
ToolErrorKind::InvalidArguments,
format!("unknown skill: {name}"),
)
})?
}
};
if skill.disable_model_invocation
&& !matches!(invocation_source, ToolInvocationSource::Host)
{
return Err(SdkToolError::new(
ToolErrorKind::PolicyDenied,
format!("skill '{name}' requires direct user invocation"),
));
}
let source_display = skill.source.to_string();
match skill.source {
crate::skills::SkillSource::BuiltIn => {
let metadata = ToolMetadata::new().operation(OperationKind::Read);
let capability = CapabilityRequest::skill(
&name,
None,
CapabilitySource::built_in_tool("skill"),
);
let access = ToolResourceAccess::shared(ToolResource::opaque(
"rho.skill.builtin",
&name,
));
let content = truncate(
format!(
"Loaded skill: {name}\nSource: {source_display}\n\n{}",
skill.contents
),
self.max_output_bytes,
);
Ok(PreparedToolInvocation::resource_aware(
[access],
[capability],
metadata,
move |_context| Box::pin(async move { Ok(ToolOutput::text(content)) }),
))
}
crate::skills::SkillSource::Filesystem { skill_file, .. } => {
let skill_directory = skill_file.parent().ok_or_else(|| {
SdkToolError::new(
ToolErrorKind::Execution,
format!(
"skill path '{}' has no parent directory",
skill_file.display()
),
)
})?;
self.prepare_fs_skill(
&name,
source_display,
&skill_file,
skill_directory,
&context,
)
}
}
})
}
}
fn preparation_workspace(
context: &ToolPreparationContext,
) -> Result<&rho_sdk::Workspace, SdkToolError> {
context.workspace().ok_or_else(|| {
SdkToolError::new(
ToolErrorKind::Execution,
"skill requires a configured workspace",
)
})
}
fn valid_skill_name(name: &str) -> bool {
!name.is_empty()
&& name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
pub(super) struct QuestionnaireTool;
impl SdkTool for QuestionnaireTool {
fn spec(&self) -> rho_sdk::model::ToolSpec {
crate::questionnaire::tool_spec()
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([])
}
fn call<'a>(&'a self, invocation: ToolInvocation, context: SdkToolContext) -> ToolFuture<'a> {
Box::pin(async move {
let request = crate::questionnaire::parse_request(invocation.into_arguments())
.map_err(|message| SdkToolError::new(ToolErrorKind::InvalidArguments, message))?;
let questions = request
.questions
.iter()
.map(host_question)
.collect::<Result<Vec<_>, _>>()?;
let title = request
.title
.clone()
.unwrap_or_else(|| "questionnaire".into());
let host_request =
HostInputRequest::questionnaire(title, questions).map_err(map_sdk_error)?;
let response = context
.request_host_input(host_request)
.await
.map_err(map_sdk_error)?;
let answers = response
.answers()
.iter()
.map(|(id, values)| crate::questionnaire::QuestionnaireAnswer {
id: id.clone(),
answer: if values.len() == 1 {
serde_json::Value::String(values[0].clone())
} else {
serde_json::Value::Array(
values
.iter()
.cloned()
.map(serde_json::Value::String)
.collect(),
)
},
})
.collect();
let content = crate::questionnaire::response_content(
&crate::questionnaire::QuestionnaireResponse { answers },
);
Ok(ToolOutput::text(content).metadata(
ToolMetadata::new().operation(OperationKind::Other("questionnaire".into())),
))
})
}
}
struct MessageParentTool {
poster: Arc<dyn crate::app::subagent_messaging::NoticePoster>,
}
impl SdkTool for MessageParentTool {
fn spec(&self) -> rho_sdk::model::ToolSpec {
rho_sdk::model::ToolSpec {
name: "message_parent".into(),
description: "Send a short plain-text notice to the parent session without waiting for a reply. Use for blockers, findings, or status the parent should see at its next turn boundary. Do not use this for questions that need an answer - use questionnaire for those. Keep the message under 8 KiB.".into(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Plain-text notice for the parent session"
}
},
"required": ["message"],
"additionalProperties": false
}),
}
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([])
}
fn call<'a>(&'a self, invocation: ToolInvocation, _context: SdkToolContext) -> ToolFuture<'a> {
Box::pin(async move {
let arguments = invocation.into_arguments();
let message = required_string(&arguments, "message").map_err(|error| {
SdkToolError::new(ToolErrorKind::InvalidArguments, error.to_string())
})?;
let message = crate::app::subagent_messaging::ValidatedMessage::parse(message)
.map_err(|error| {
SdkToolError::new(ToolErrorKind::InvalidArguments, error.to_string())
})?;
self.poster
.post(message)
.map_err(|error| SdkToolError::new(ToolErrorKind::Execution, error.to_string()))?;
Ok(
ToolOutput::text("notice queued for the parent session").metadata(
ToolMetadata::new().operation(OperationKind::Other("message_parent".into())),
),
)
})
}
}
fn host_question(
question: &crate::questionnaire::QuestionnaireQuestion,
) -> Result<HostQuestion, SdkToolError> {
use crate::questionnaire::QuestionnaireQuestionKind;
let selection = match question.kind {
QuestionnaireQuestionKind::MultiSelect => SelectionMode::Many,
QuestionnaireQuestionKind::Choice
| QuestionnaireQuestionKind::Confirm
| QuestionnaireQuestionKind::Text => SelectionMode::One,
};
let choices = match question.kind {
QuestionnaireQuestionKind::Choice | QuestionnaireQuestionKind::MultiSelect => question
.choices
.iter()
.map(|choice| {
let host = HostChoice::new(&choice.label, &choice.label);
match &choice.description {
Some(description) => host.description(description),
None => host,
}
})
.collect(),
QuestionnaireQuestionKind::Confirm => {
vec![HostChoice::new("yes", "Yes"), HostChoice::new("no", "No")]
}
QuestionnaireQuestionKind::Text => vec![HostChoice::new("other", "Other")],
};
let mut host = HostQuestion::new(&question.id, &question.question, choices, selection)
.map_err(map_sdk_error)?;
if question.allow_other || matches!(question.kind, QuestionnaireQuestionKind::Text) {
host = host.allow_other();
}
if let Some(header) = &question.header {
host = host.header(header);
}
if let Some(help) = &question.help {
host = host.help(help);
}
if let Some(default) = &question.default {
host = host.default_value(default.clone());
}
host = host.default_selection(question.default_selection.into());
if !question.required {
host = host.optional();
}
Ok(host)
}
fn map_sdk_error(error: rho_sdk::Error) -> SdkToolError {
match error {
rho_sdk::Error::Cancelled => SdkToolError::cancelled(),
error => SdkToolError::new(ToolErrorKind::Execution, error.to_string()),
}
}