use async_trait::async_trait;
use crate::data::Item;
use crate::error::{EngineError, Result};
use crate::nodes::{NodeContext, NodeExecutor, NodeOutput};
#[derive(Debug, Default, Clone)]
pub struct ToolCallNode;
#[async_trait]
impl NodeExecutor for ToolCallNode {
async fn execute(&self, ctx: NodeContext<'_>) -> Result<NodeOutput> {
let slug = ctx
.node
.config
.get("slug")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
EngineError::Capability("tool_call node: missing `slug` in config".to_string())
})?;
let args = ctx
.node
.config
.get("args")
.cloned()
.unwrap_or(serde_json::Value::Null);
let conn = ctx
.node
.config
.get("connection_ref")
.and_then(serde_json::Value::as_str);
let result = ctx.caps.tools.invoke(slug, args, conn).await?;
Ok(NodeOutput::main(vec![Item::new(result)]))
}
}
#[cfg(test)]
mod tests {
use crate::caps::mock::mock_capabilities;
use crate::compiler::compile;
use crate::engine::run;
use crate::model::{Edge, Node, NodeKind, WorkflowGraph};
use serde_json::{Value, json};
fn wf(kind: NodeKind, config: Value) -> WorkflowGraph {
WorkflowGraph {
nodes: vec![
Node {
id: "t".into(),
kind: NodeKind::Trigger,
type_version: 1,
name: "t".into(),
config: Value::Null,
ports: vec![],
position: None,
},
Node {
id: "n".into(),
kind,
type_version: 1,
name: "n".into(),
config,
ports: vec![],
position: None,
},
],
edges: vec![Edge {
from_node: "t".into(),
from_port: "main".into(),
to_node: "n".into(),
to_port: "main".into(),
}],
..Default::default()
}
}
#[tokio::test]
async fn tool_call_invokes_slug_with_args() {
let graph = wf(
NodeKind::ToolCall,
json!({ "slug": "slack.post", "args": { "x": 1 } }),
);
let compiled = compile(&graph).expect("compile");
let out = run(&compiled, Value::Null, &mock_capabilities())
.await
.expect("run");
assert_eq!(
out.output["nodes"]["n"]["items"][0]["json"]["tool"],
"slack.post"
);
assert_eq!(out.output["nodes"]["n"]["items"][0]["json"]["args"]["x"], 1);
}
#[tokio::test]
async fn tool_call_threads_connection_ref() {
let graph = wf(
NodeKind::ToolCall,
json!({ "slug": "slack.post", "connection_ref": "composio:slack:acct_1" }),
);
let compiled = compile(&graph).expect("compile");
let out = run(&compiled, Value::Null, &mock_capabilities())
.await
.expect("run");
assert_eq!(
out.output["nodes"]["n"]["items"][0]["json"]["connection"],
"composio:slack:acct_1"
);
}
use super::ToolCallNode;
use crate::error::EngineError;
use crate::nodes::{NodeContext, NodeExecutor};
fn tool_node(config: Value) -> Node {
Node {
id: "n".into(),
kind: NodeKind::ToolCall,
type_version: 1,
name: "n".into(),
config,
ports: vec![],
position: None,
}
}
#[tokio::test]
async fn missing_slug_is_a_capability_error() {
let node = tool_node(json!({ "args": { "x": 1 } }));
let input = vec![];
let caps = mock_capabilities();
let run_meta = Value::Null;
let ctx = NodeContext {
node: &node,
input: &input,
run: &run_meta,
caps: &caps,
};
let err = ToolCallNode
.execute(ctx)
.await
.expect_err("missing slug must error");
assert!(
matches!(err, EngineError::Capability(ref m) if m.contains("slug")),
"expected a capability error mentioning `slug`, got: {err:?}"
);
}
#[tokio::test]
async fn missing_args_default_to_null() {
let node = tool_node(json!({ "slug": "noop" }));
let input = vec![];
let caps = mock_capabilities();
let run_meta = Value::Null;
let ctx = NodeContext {
node: &node,
input: &input,
run: &run_meta,
caps: &caps,
};
let out = ToolCallNode.execute(ctx).await.expect("execute");
assert_eq!(out.items.len(), 1);
assert_eq!(out.items[0].json["tool"], "noop");
assert_eq!(out.items[0].json["args"], Value::Null);
assert_eq!(out.items[0].json["connection"], Value::Null);
}
}