use async_trait::async_trait;
use klieo_core::agent::{Agent, AgentContext};
use klieo_core::error::ToolError;
use klieo_core::llm::ToolDef;
use klieo_core::tool::{Tool, ToolCtx, ToolInvoker};
use serde::de::DeserializeOwned;
use serde::Serialize;
use std::sync::Arc;
pub type AgentContextFactory = Arc<dyn Fn() -> AgentContext + Send + Sync + 'static>;
pub type AgentContextTransform = Arc<dyn Fn(AgentContext) -> AgentContext + Send + Sync + 'static>;
pub struct AgentTool<A>
where
A: Agent + 'static,
A::Input: DeserializeOwned + Send + 'static,
A::Output: Serialize + Send + 'static,
{
agent: Arc<A>,
name: String,
description: String,
input_schema: serde_json::Value,
ctx_factory: AgentContextFactory,
ctx_transform: Option<AgentContextTransform>,
}
impl<A> AgentTool<A>
where
A: Agent + 'static,
A::Input: DeserializeOwned + Send + 'static,
A::Output: Serialize + Send + 'static,
{
pub fn new(
agent: A,
input_schema: serde_json::Value,
ctx_factory: AgentContextFactory,
) -> Self {
let name = agent.name().to_string();
let description = format!("klieo agent: {name}");
Self {
agent: Arc::new(agent),
name,
description,
input_schema,
ctx_factory,
ctx_transform: None,
}
}
#[must_use]
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = description.into();
self
}
#[must_use]
pub fn with_context_transform(mut self, transform: AgentContextTransform) -> Self {
self.ctx_transform = Some(transform);
self
}
async fn run_agent(
&self,
args: serde_json::Value,
tool_ctx: ToolCtx,
) -> Result<serde_json::Value, ToolError> {
let input = self.decode_input(args)?;
let ctx = self.build_context(tool_ctx);
let output = self.agent.run(ctx, input).await.map_err(|e| {
tracing::warn!(agent = %self.name, error = %e, "exposed agent execution failed");
ToolError::Permanent("agent execution failed".into())
})?;
serde_json::to_value(output).map_err(|e| {
tracing::warn!(agent = %self.name, error = %e, "encode of agent output failed");
ToolError::Permanent("agent output not serialisable".into())
})
}
fn decode_input(&self, args: serde_json::Value) -> Result<A::Input, ToolError> {
serde_json::from_value(args).map_err(|e| {
tracing::warn!(agent = %self.name, error = %e, "decode of agent tool args failed");
ToolError::InvalidArgs("arguments do not match inputSchema".into())
})
}
fn build_context(&self, tool_ctx: ToolCtx) -> AgentContext {
let mut ctx = (self.ctx_factory)();
ctx.cancel = tool_ctx.cancel.child_token();
ctx.progress = tool_ctx.progress.clone();
if let Some(principal) = tool_ctx.caller_principal.as_ref() {
ctx = ctx.with_tenant_label(klieo_core::principal_hash(principal.as_str()));
}
if let Some(anchor) = tool_ctx.parent_anchor.as_ref() {
ctx = ctx.with_parent_anchor(anchor.as_str().to_string());
}
if let Some(transform) = self.ctx_transform.as_ref() {
ctx = transform(ctx);
}
ctx
}
}
#[async_trait]
impl<A> Tool for AgentTool<A>
where
A: Agent + 'static,
A::Input: DeserializeOwned + Send + 'static,
A::Output: Serialize + Send + 'static,
{
fn name(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn json_schema(&self) -> &serde_json::Value {
&self.input_schema
}
async fn invoke(
&self,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<serde_json::Value, ToolError> {
self.run_agent(args, ctx).await
}
}
#[async_trait]
impl<A> ToolInvoker for AgentTool<A>
where
A: Agent + 'static,
A::Input: DeserializeOwned + Send + 'static,
A::Output: Serialize + Send + 'static,
{
fn catalogue(&self) -> Vec<ToolDef> {
vec![ToolDef::new(
self.name.clone(),
self.description.clone(),
self.input_schema.clone(),
)]
}
async fn invoke(
&self,
name: &str,
args: serde_json::Value,
ctx: ToolCtx,
) -> Result<serde_json::Value, ToolError> {
if name != self.name {
return Err(ToolError::UnknownTool(name.to_string()));
}
self.run_agent(args, ctx).await
}
}
#[cfg(test)]
mod tests {
use crate::invoker::ChainedInvoker;
use async_trait::async_trait;
use klieo_core::agent::{Agent, AgentContext};
use klieo_core::error::ToolError;
use klieo_core::ids::ThreadId;
use klieo_core::llm::ToolDef;
use klieo_core::test_utils::{fake_context, noop_bus, FakeLlmClient, FakeLlmStep};
use klieo_core::tool::ToolCtx;
use klieo_core::Episode;
use std::sync::Arc;
use super::{AgentContextFactory, AgentTool};
struct GreeterAgent;
#[async_trait]
impl Agent for GreeterAgent {
type Input = serde_json::Value;
type Output = serde_json::Value;
type Error = std::convert::Infallible;
fn name(&self) -> &str {
"greeter"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
_ctx: AgentContext,
input: serde_json::Value,
) -> Result<serde_json::Value, Self::Error> {
let who = input.get("who").and_then(|v| v.as_str()).unwrap_or("world");
Ok(serde_json::json!({ "greeting": format!("hello {who}") }))
}
}
#[derive(serde::Deserialize, serde::Serialize)]
struct GreetInput {
who: String,
}
struct StrictGreeterAgent;
#[async_trait]
impl Agent for StrictGreeterAgent {
type Input = GreetInput;
type Output = serde_json::Value;
type Error = std::convert::Infallible;
fn name(&self) -> &str {
"strict-greeter"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
_ctx: AgentContext,
input: GreetInput,
) -> Result<serde_json::Value, Self::Error> {
Ok(serde_json::json!({ "greeting": format!("hello {}", input.who) }))
}
}
struct ObserverAgent;
#[derive(serde::Serialize)]
struct ObserverOut {
cancelled: bool,
has_progress: bool,
}
#[async_trait]
impl Agent for ObserverAgent {
type Input = serde_json::Value;
type Output = ObserverOut;
type Error = std::convert::Infallible;
fn name(&self) -> &str {
"observer"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
ctx: AgentContext,
_input: serde_json::Value,
) -> Result<ObserverOut, Self::Error> {
Ok(ObserverOut {
cancelled: ctx.cancel.is_cancelled(),
has_progress: ctx.progress.is_some(),
})
}
}
struct EchoLoopAgent;
#[async_trait]
impl Agent for EchoLoopAgent {
type Input = serde_json::Value;
type Output = serde_json::Value;
type Error = klieo_core::Error;
fn name(&self) -> &str {
"echo-loop"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
ctx: AgentContext,
_input: serde_json::Value,
) -> Result<serde_json::Value, Self::Error> {
let out = klieo_core::runtime::run_steps(
&ctx,
"",
ThreadId::new("echo-loop-thread"),
klieo_core::runtime::RunOptions::default(),
)
.await?;
Ok(serde_json::Value::String(out))
}
}
struct FailingAgent;
#[async_trait]
impl Agent for FailingAgent {
type Input = serde_json::Value;
type Output = serde_json::Value;
type Error = std::io::Error;
fn name(&self) -> &str {
"failing"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
_ctx: AgentContext,
_input: serde_json::Value,
) -> Result<serde_json::Value, Self::Error> {
Err(std::io::Error::other(
"internal secret-abc leaked from https://internal.example/",
))
}
}
struct UnserializableOutput;
impl serde::Serialize for UnserializableOutput {
fn serialize<S: serde::Serializer>(&self, _serializer: S) -> Result<S::Ok, S::Error> {
Err(serde::ser::Error::custom(
"intentionally unserialisable: internal-detail-xyz",
))
}
}
struct UnserializableAgent;
#[async_trait]
impl Agent for UnserializableAgent {
type Input = serde_json::Value;
type Output = UnserializableOutput;
type Error = std::convert::Infallible;
fn name(&self) -> &str {
"unserializable"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
_ctx: AgentContext,
_input: serde_json::Value,
) -> Result<UnserializableOutput, Self::Error> {
Ok(UnserializableOutput)
}
}
fn object_schema() -> serde_json::Value {
serde_json::json!({"type": "object"})
}
fn ctx_factory() -> AgentContextFactory {
Arc::new(|| fake_context("agent-tool-test"))
}
fn tool_ctx() -> ToolCtx {
let (pubsub, _, kv, jobs) = noop_bus();
ToolCtx::new(pubsub, kv, jobs)
}
struct PlainEchoTool;
#[async_trait]
impl klieo_core::tool::Tool for PlainEchoTool {
fn name(&self) -> &str {
"plain-echo"
}
fn description(&self) -> &str {
"echoes args back"
}
fn json_schema(&self) -> &serde_json::Value {
static SCHEMA: std::sync::OnceLock<serde_json::Value> = std::sync::OnceLock::new();
SCHEMA.get_or_init(|| serde_json::json!({"type": "object"}))
}
async fn invoke(
&self,
args: serde_json::Value,
_ctx: ToolCtx,
) -> Result<serde_json::Value, ToolError> {
Ok(args)
}
}
#[tokio::test]
async fn tool_invoke_runs_agent_in_process_and_returns_serialized_output() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory());
let out = tool
.invoke(serde_json::json!({"who": "ferris"}), tool_ctx())
.await
.unwrap();
assert_eq!(out, serde_json::json!({"greeting": "hello ferris"}));
}
#[tokio::test]
async fn tool_exposes_agent_name_description_and_schema() {
use klieo_core::tool::Tool;
let schema = object_schema();
let tool = AgentTool::new(GreeterAgent, schema.clone(), ctx_factory());
assert_eq!(tool.name(), "greeter");
assert_eq!(tool.description(), "klieo agent: greeter");
assert_eq!(tool.json_schema(), &schema);
}
#[tokio::test]
async fn tool_invoker_dispatches_by_matching_name() {
use klieo_core::tool::ToolInvoker;
let tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory());
let out = tool
.invoke("greeter", serde_json::json!({"who": "world"}), tool_ctx())
.await
.unwrap();
assert_eq!(out, serde_json::json!({"greeting": "hello world"}));
}
#[tokio::test]
async fn tool_invoker_rejects_wrong_tool_name() {
use klieo_core::tool::ToolInvoker;
let tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory());
let err = tool
.invoke("not-greeter", serde_json::json!({}), tool_ctx())
.await
.unwrap_err();
assert!(matches!(err, ToolError::UnknownTool(name) if name == "not-greeter"));
}
#[tokio::test]
async fn tool_invoker_catalogue_lists_agent_as_single_tool() {
use klieo_core::tool::ToolInvoker;
let tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory());
let cat = tool.catalogue();
assert_eq!(cat.len(), 1);
assert_eq!(cat[0].name, "greeter");
}
#[tokio::test]
async fn malformed_args_return_invalid_args_not_panic() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(StrictGreeterAgent, object_schema(), ctx_factory());
let err = tool
.invoke(serde_json::json!({"unexpected": "shape"}), tool_ctx())
.await
.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgs(_)));
}
#[tokio::test]
async fn malformed_args_detail_is_not_leaked_on_the_wire() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(StrictGreeterAgent, object_schema(), ctx_factory());
let err = tool
.invoke(serde_json::json!({"unexpected": "shape"}), tool_ctx())
.await
.unwrap_err();
let ToolError::InvalidArgs(msg) = err else {
panic!("expected InvalidArgs, got {err:?}");
};
assert_eq!(msg, "arguments do not match inputSchema");
assert!(!msg.contains("missing field"), "serde detail leaked: {msg}");
assert!(!msg.contains("who"), "field name leaked: {msg}");
}
#[tokio::test]
async fn agent_execution_error_is_sanitised_and_does_not_leak_detail() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(FailingAgent, object_schema(), ctx_factory());
let err = tool
.invoke(serde_json::json!({}), tool_ctx())
.await
.unwrap_err();
let ToolError::Permanent(msg) = err else {
panic!("expected Permanent, got {err:?}");
};
assert_eq!(msg, "agent execution failed");
assert!(!msg.contains("secret-abc"));
assert!(!msg.contains("https://"));
}
#[tokio::test]
async fn output_encode_failure_is_sanitised() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(UnserializableAgent, object_schema(), ctx_factory());
let err = tool
.invoke(serde_json::json!({}), tool_ctx())
.await
.unwrap_err();
let ToolError::Permanent(msg) = err else {
panic!("expected Permanent, got {err:?}");
};
assert_eq!(msg, "agent output not serialisable");
assert!(
!msg.contains("internal-detail-xyz"),
"encode detail leaked: {msg}"
);
}
#[tokio::test]
async fn chained_invoker_resolves_agent_tool_alongside_a_normal_tool() {
use klieo_core::tool::ToolInvoker;
let agent_tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory());
let inv = ChainedInvoker::new()
.with_tool(Arc::new(agent_tool))
.unwrap()
.with_tool(Arc::new(PlainEchoTool))
.unwrap();
let agent_out = inv
.invoke("greeter", serde_json::json!({"who": "chained"}), tool_ctx())
.await
.unwrap();
assert_eq!(agent_out, serde_json::json!({"greeting": "hello chained"}));
let plain_out = inv
.invoke("plain-echo", serde_json::json!({"x": 1}), tool_ctx())
.await
.unwrap();
assert_eq!(plain_out, serde_json::json!({"x": 1}));
let catalogue = inv.catalogue();
let cat_names: Vec<&str> = catalogue.iter().map(|d| d.name.as_str()).collect();
assert!(cat_names.contains(&"greeter"));
assert!(cat_names.contains(&"plain-echo"));
}
#[tokio::test]
async fn cancel_token_is_derived_from_tool_ctx_when_live() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(ObserverAgent, object_schema(), ctx_factory());
let out = tool
.invoke(serde_json::json!({}), tool_ctx())
.await
.unwrap();
assert_eq!(out["cancelled"], false);
}
#[tokio::test]
async fn cancel_token_propagates_when_tool_ctx_is_already_cancelled() {
use klieo_core::tool::Tool;
let ctx = tool_ctx();
ctx.cancel.cancel();
let tool = AgentTool::new(ObserverAgent, object_schema(), ctx_factory());
let out = tool.invoke(serde_json::json!({}), ctx).await.unwrap();
assert_eq!(out["cancelled"], true);
}
#[tokio::test]
async fn progress_sender_is_propagated_from_tool_ctx() {
use klieo_core::tool::Tool;
let (tx, _rx) = tokio::sync::broadcast::channel::<klieo_core::AgentEvent>(8);
let ctx = tool_ctx().with_progress(tx);
let tool = AgentTool::new(ObserverAgent, object_schema(), ctx_factory());
let out = tool.invoke(serde_json::json!({}), ctx).await.unwrap();
assert_eq!(out["has_progress"], true);
}
#[tokio::test]
async fn progress_defaults_to_none_when_tool_ctx_has_none() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(ObserverAgent, object_schema(), ctx_factory());
let out = tool
.invoke(serde_json::json!({}), tool_ctx())
.await
.unwrap();
assert_eq!(out["has_progress"], false);
}
#[tokio::test]
async fn tenant_label_is_installed_from_caller_principal() {
use klieo_core::tool::Tool;
const PRINCIPAL: &str = "alice@example.com";
let mut seed = fake_context("echo-loop");
seed.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
let episodic = seed.episodic.clone();
let run_id = seed.run_id;
let slot = Arc::new(std::sync::Mutex::new(Some(seed)));
let factory: AgentContextFactory = Arc::new(move || slot.lock().unwrap().take().unwrap());
let tool = AgentTool::new(EchoLoopAgent, object_schema(), factory);
let ctx = tool_ctx().with_caller_principal(PRINCIPAL.into());
tool.invoke(serde_json::json!({}), ctx).await.unwrap();
let expected = klieo_core::principal_hash(PRINCIPAL);
let episodes = episodic.replay(run_id).await.unwrap();
let labels: Vec<&str> = episodes
.iter()
.filter_map(|e| match e {
Episode::RunAttributed { tenant_label } => Some(tenant_label.as_str()),
_ => None,
})
.collect();
assert_eq!(labels, vec![expected.as_str()]);
for ep in &episodes {
let payload = serde_json::to_string(ep).unwrap();
assert!(
!payload.contains(PRINCIPAL),
"raw principal leaked: {payload}"
);
}
}
#[tokio::test]
async fn parent_anchor_is_recorded_verbatim_as_run_origin() {
use klieo_core::tool::Tool;
const ANCHOR: &str = "sha256:deadbeefcafe0123";
let mut seed = fake_context("echo-loop");
seed.llm =
Arc::new(FakeLlmClient::new("fake").with_steps(vec![FakeLlmStep::Text("done".into())]));
let episodic = seed.episodic.clone();
let run_id = seed.run_id;
let slot = Arc::new(std::sync::Mutex::new(Some(seed)));
let factory: AgentContextFactory = Arc::new(move || slot.lock().unwrap().take().unwrap());
let tool = AgentTool::new(EchoLoopAgent, object_schema(), factory);
let ctx = tool_ctx().with_parent_anchor(ANCHOR.into());
tool.invoke(serde_json::json!({}), ctx).await.unwrap();
let episodes = episodic.replay(run_id).await.unwrap();
let anchors: Vec<&str> = episodes
.iter()
.filter_map(|e| match e {
Episode::RunOrigin { parent_anchor } => Some(parent_anchor.as_str()),
_ => None,
})
.collect();
assert_eq!(anchors, vec![ANCHOR]);
}
#[tokio::test]
async fn context_transform_hook_is_applied_before_the_agent_runs() {
use klieo_core::tool::Tool;
struct ReportsTenantLabelAgent;
#[async_trait]
impl Agent for ReportsTenantLabelAgent {
type Input = serde_json::Value;
type Output = serde_json::Value;
type Error = std::convert::Infallible;
fn name(&self) -> &str {
"reports-tenant-label"
}
fn system_prompt(&self) -> &str {
""
}
fn tools(&self) -> &[ToolDef] {
&[]
}
async fn run(
&self,
ctx: AgentContext,
_input: serde_json::Value,
) -> Result<serde_json::Value, Self::Error> {
Ok(serde_json::json!({ "tenant_label": ctx.tenant_label() }))
}
}
let tool = AgentTool::new(ReportsTenantLabelAgent, object_schema(), ctx_factory())
.with_context_transform(Arc::new(|ctx: AgentContext| {
ctx.with_tenant_label("governed".into())
}));
let out = tool
.invoke(serde_json::json!({}), tool_ctx())
.await
.unwrap();
assert_eq!(out["tenant_label"], serde_json::json!("governed"));
}
#[tokio::test]
async fn with_description_overrides_the_default_description() {
use klieo_core::tool::Tool;
let tool = AgentTool::new(GreeterAgent, object_schema(), ctx_factory())
.with_description("custom description");
assert_eq!(tool.description(), "custom description");
}
}