use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc};
use rho_sdk::tool::{
OperationKind, PreparedToolInvocation, Tool, ToolContext, ToolError, ToolErrorKind,
ToolInvocation, ToolMetadata, ToolOutput, ToolPreparationContext, ToolPrepareFuture,
ToolSecurity,
};
use serde::{Deserialize, Serialize};
use crate::workflow::{PlanId, RunId};
use super::sdk_registry::StaticToolBundle;
pub(crate) const NAME: &str = "workflow";
pub(crate) fn sdk_bundle(
service: Arc<dyn WorkflowToolService>,
max_output_bytes: usize,
) -> StaticToolBundle {
StaticToolBundle::new(vec![Arc::new(WorkflowTool::new(service, max_output_bytes))])
}
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)]
pub(crate) enum WorkflowToolRequest {
Validate {
file: String,
#[serde(default)]
inputs: BTreeMap<String, serde_json::Value>,
},
Plan {
file: String,
#[serde(default)]
inputs: BTreeMap<String, serde_json::Value>,
},
Run {
plan_id: PlanId,
},
Status {
run_id: RunId,
},
Cancel {
run_id: RunId,
},
Resume {
run_id: RunId,
#[serde(default)]
recover_uncertain: bool,
},
}
impl WorkflowToolRequest {
fn operation(&self) -> OperationKind {
match self {
Self::Validate { .. } | Self::Status { .. } => OperationKind::Read,
Self::Plan { .. } | Self::Run { .. } | Self::Cancel { .. } | Self::Resume { .. } => {
OperationKind::Other("workflow".into())
}
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct WorkflowArtifactSummary {
pub(crate) kind: crate::workflow::ArtifactKind,
#[serde(flatten)]
pub(crate) artifact: crate::workflow::ArtifactRef,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WorkflowRunStateSummary {
Planned,
Running,
Cancelling,
Completed,
NeedsRecovery,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WorkflowCancellationStateSummary {
Acknowledged,
Pending,
AlreadyCompleted,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum WorkflowNodeStateSummary {
Pending,
Ready,
Running,
Success,
Failure,
Denial,
Cancellation,
Skipped,
Blocked,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct WorkflowNodeSummary {
pub(crate) node_id: String,
pub(crate) state: WorkflowNodeStateSummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) attempt: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub(crate) artifacts: Vec<WorkflowArtifactSummary>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub(crate) struct WorkflowDiagnosticSummary {
pub(crate) severity: String,
pub(crate) code: String,
pub(crate) message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) source: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) line: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) column: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
pub(crate) enum WorkflowToolResult {
Validate {
valid: bool,
diagnostics: Vec<WorkflowDiagnosticSummary>,
},
Plan {
plan_id: String,
graph_digest: String,
workflow_name: String,
node_count: u64,
},
Run {
run_id: String,
graph_digest: String,
state: WorkflowRunStateSummary,
nodes: Vec<WorkflowNodeSummary>,
},
Status {
run_id: String,
graph_digest: String,
state: WorkflowRunStateSummary,
nodes: Vec<WorkflowNodeSummary>,
},
Cancel {
run_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
cancellation_state: WorkflowCancellationStateSummary,
state: WorkflowRunStateSummary,
},
Resume {
run_id: String,
graph_digest: String,
state: WorkflowRunStateSummary,
nodes: Vec<WorkflowNodeSummary>,
},
}
pub(crate) trait WorkflowToolService: Send + Sync {
fn prepare(
&self,
_request: &WorkflowToolRequest,
) -> Result<Vec<rho_sdk::CapabilityRequest>, ToolError> {
Ok(Vec::new())
}
fn execute<'a>(
&'a self,
request: WorkflowToolRequest,
context: &'a ToolContext,
) -> Pin<Box<dyn Future<Output = Result<WorkflowToolResult, ToolError>> + Send + 'a>>;
}
pub(crate) struct WorkflowTool {
service: Arc<dyn WorkflowToolService>,
max_output_bytes: usize,
}
impl WorkflowTool {
pub(crate) fn new(service: Arc<dyn WorkflowToolService>, max_output_bytes: usize) -> Self {
Self {
service,
max_output_bytes: max_output_bytes.max(1),
}
}
}
impl Tool for WorkflowTool {
fn spec(&self) -> rho_sdk::model::ToolSpec {
rho_sdk::model::ToolSpec {
name: NAME.into(),
description: "Validate, freeze, run, inspect, cancel, or resume a Rho workflow. Run and resume start in the background and return immediately with a run id. Completions arrive automatically at the next turn boundary (batched); do not poll in a loop. Use status for a live check or after delivery, and cancel to stop. Results are bounded summaries; read artifacts separately.".into(),
input_schema: workflow_schema(),
}
}
fn security(&self) -> ToolSecurity {
ToolSecurity::built_in([
rho_sdk::CapabilityKind::Read,
rho_sdk::CapabilityKind::Write,
rho_sdk::CapabilityKind::Process,
])
}
fn prepare<'a>(
&'a self,
invocation: ToolInvocation,
_context: ToolPreparationContext,
) -> ToolPrepareFuture<'a> {
Box::pin(async move {
let request: WorkflowToolRequest =
serde_json::from_value(invocation.arguments().clone()).map_err(|error| {
ToolError::new(
ToolErrorKind::InvalidArguments,
format!("invalid workflow operation: {error}"),
)
})?;
let operation = request.operation();
let capabilities = self.service.prepare(&request)?;
let service = Arc::clone(&self.service);
let max_output_bytes = self.max_output_bytes;
Ok(PreparedToolInvocation::exclusive_with_capabilities(
capabilities,
ToolMetadata::new().operation(operation.clone()),
move |context| {
Box::pin(async move {
let result = service.execute(request, &context).await?;
let content = bounded_result(&result, max_output_bytes)?;
Ok(ToolOutput::text(content)
.metadata(ToolMetadata::new().operation(operation)))
})
},
))
})
}
}
fn bounded_result(
result: &WorkflowToolResult,
max_output_bytes: usize,
) -> Result<String, ToolError> {
let serialized = serde_json::to_string(result)
.map_err(|error| ToolError::new(ToolErrorKind::Execution, error.to_string()))?;
if serialized.len() <= max_output_bytes {
return Ok(serialized);
}
let summary = serde_json::json!({
"truncated": true,
"reason": "workflow summary exceeded the tool output budget",
"requested_bytes": serialized.len(),
"limit_bytes": max_output_bytes,
})
.to_string();
if summary.len() <= max_output_bytes {
return Ok(summary);
}
Err(ToolError::new(
ToolErrorKind::Execution,
format!(
"workflow tool output budget is too small: accepted limit {max_output_bytes}, required {}",
summary.len()
),
))
}
fn workflow_schema() -> serde_json::Value {
let source_operation = |action: &str| {
serde_json::json!({
"type": "object",
"properties": {
"action": {"const": action},
"file": {"type": "string", "minLength": 1},
"inputs": {
"type": "object",
"description": "Explicit input values. Inputs are persisted and are not a secret store."
}
},
"required": ["action", "file"],
"additionalProperties": false
})
};
let id_operation = |action: &str, field: &str| {
serde_json::json!({
"type": "object",
"properties": {
"action": {"const": action},
(field): {"type": "string", "minLength": 1}
},
"required": ["action", field],
"additionalProperties": false
})
};
serde_json::json!({
"oneOf": [
source_operation("validate"),
source_operation("plan"),
id_operation("run", "plan_id"),
id_operation("status", "run_id"),
id_operation("cancel", "run_id"),
serde_json::json!({
"type": "object",
"properties": {
"action": {"const": "resume"},
"run_id": {"type": "string", "minLength": 1},
"recover_uncertain": {
"type": "boolean",
"description": "Confirm no prior process remains before relaunching uncertain attempts."
}
},
"required": ["action", "run_id"],
"additionalProperties": false
})
]
})
}
#[cfg(test)]
#[path = "workflow_tests.rs"]
mod tests;