use super::InputStream;
use super::reflection::{GolemReflectError, SchemaRef};
use super::tool_client::{self, InvocationResult, ToolError, ToolInvocation};
use crate::golem_agentic::golem::tool::host::{self, ToolRpc};
use crate::schema::tool::canonical::CanonicalSurfaceRef;
use crate::schema::tool::validation::validate_tool;
use crate::schema::tool::wit::decode_tool;
use crate::schema::tool::{
CommandBody, Constraint, Doc, FlagShape, OptionShape, OptionSpec, Ref, Tool,
};
use crate::schema::validation::subtyping::is_equivalent_cross_graph;
use crate::schema::{SchemaGraph, SchemaValue, TypedSchemaValue};
use std::fmt::{Display, Formatter};
use std::marker::PhantomData;
use std::rc::Rc;
use std::sync::Arc;
#[derive(Debug)]
#[allow(clippy::large_enum_variant)]
pub enum ToolReflectionError {
NotFound(String),
InvalidMetadata(String),
InvalidInput(GolemReflectError),
Tool(ToolError<ReflectedToolCustomError>),
}
impl Display for ToolReflectionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::NotFound(name) => write!(f, "tool `{name}` was not found"),
Self::InvalidMetadata(message) => write!(f, "invalid tool metadata: {message}"),
Self::InvalidInput(error) => write!(f, "invalid tool input: {error}"),
Self::Tool(error) => error.fmt(f),
}
}
}
impl std::error::Error for ToolReflectionError {}
#[derive(Clone, Debug, PartialEq)]
pub struct ReflectedToolCustomError {
pub name: String,
pub payload: TypedSchemaValue,
}
impl Display for ReflectedToolCustomError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "tool error `{}`", self.name)
}
}
#[derive(Clone, Debug)]
pub struct ToolArgument {
pub kind: ToolArgumentKind,
pub name: String,
pub aliases: Vec<String>,
pub short: Option<char>,
pub schema: SchemaRef,
pub required: bool,
pub default: Option<SchemaValue>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ToolArgumentKind {
Positional,
Tail,
Option,
Flag,
}
fn flag_default(shape: &FlagShape) -> SchemaValue {
match shape {
FlagShape::BoolFlag(shape) => SchemaValue::Bool(shape.default),
FlagShape::CountFlag(_) => SchemaValue::U32(0),
}
}
fn option_omitted_default(spec: &OptionSpec) -> Option<SchemaValue> {
match spec.shape {
OptionShape::Scalar(_) | OptionShape::OptionalScalar(_) => spec.default.clone(),
OptionShape::RepeatableList(_) | OptionShape::RepeatableMap(_) => None,
}
}
fn value_present(value: &SchemaValue, default: Option<&SchemaValue>, flag: bool) -> bool {
if flag {
return default.is_some_and(|default| default != value);
}
if default == Some(value) {
return false;
}
match value {
SchemaValue::Option { inner } => inner.is_some(),
SchemaValue::List { elements } | SchemaValue::FixedList { elements } => {
!elements.is_empty()
}
SchemaValue::Map { entries } => !entries.is_empty(),
SchemaValue::Bool(value) => *value,
SchemaValue::U32(value) => *value != 0,
_ => true,
}
}
fn value_matches(value: &SchemaValue, expected: &SchemaValue) -> bool {
if value == expected {
return true;
}
match value {
SchemaValue::Option { inner } => inner
.as_ref()
.is_some_and(|value| value_matches(value, expected)),
SchemaValue::List { elements } | SchemaValue::FixedList { elements } => {
elements.iter().any(|value| value_matches(value, expected))
}
SchemaValue::Map { entries } => entries
.iter()
.any(|(_, value)| value_matches(value, expected)),
_ => false,
}
}
#[derive(Clone, Debug)]
pub struct ToolType {
lookup_name: String,
definition: Arc<Tool>,
schema: Arc<SchemaGraph>,
implemented_by: crate::ComponentId,
}
impl ToolType {
fn from_registered(registered: host::RegisteredTool) -> Result<Self, ToolReflectionError> {
let definition = decode_tool(registered.definition)
.map_err(|error| ToolReflectionError::InvalidMetadata(error.to_string()))?;
validate_tool(&definition).map_err(|errors| {
ToolReflectionError::InvalidMetadata(
errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; "),
)
})?;
let schema = Arc::new(definition.schema.clone());
Ok(Self {
lookup_name: registered.lookup_name,
definition: Arc::new(definition),
schema,
implemented_by: registered.implemented_by.into(),
})
}
pub fn lookup_name(&self) -> &str {
&self.lookup_name
}
pub fn definition(&self) -> &Tool {
&self.definition
}
pub fn implemented_by(&self) -> &crate::ComponentId {
&self.implemented_by
}
pub fn root(&self) -> ToolNode {
ToolNode {
tool: self.clone(),
index: 0,
path: Vec::new(),
}
}
pub fn node(&self, path: &[&str]) -> Option<ToolNode> {
let mut index = 0usize;
let mut canonical_path = Vec::with_capacity(path.len());
for part in path {
index = self
.definition
.commands
.nodes
.get(index)?
.subcommands
.iter()
.find_map(|child| {
let child = child.as_usize()?;
let node = self.definition.commands.nodes.get(child)?;
(node.name == *part || node.aliases.iter().any(|alias| alias == part))
.then_some(child)
})?;
canonical_path.push(self.definition.commands.nodes[index].name.clone());
}
Some(ToolNode {
tool: self.clone(),
index,
path: canonical_path,
})
}
pub fn command(&self, path: &[&str]) -> Result<ToolCommand, ToolReflectionError> {
let path = path
.iter()
.map(|part| (*part).to_string())
.collect::<Vec<_>>();
let index = self
.definition
.command_index_by_path(&path)
.ok_or_else(|| {
ToolReflectionError::InvalidMetadata(format!(
"command `{}` has no body",
path.join(" ")
))
})?;
let input = self
.definition
.canonical_input_model(index)
.map_err(|error| ToolReflectionError::InvalidMetadata(error.to_string()))?;
let body = self.definition.commands.nodes[index]
.body
.as_ref()
.expect("resolved body");
let mut arguments = Vec::with_capacity(input.fields.len());
let mut json_defaults = Vec::with_capacity(input.fields.len());
let surfaces = self.definition.canonical_input_surfaces(index);
input
.fields
.iter()
.zip(surfaces)
.for_each(|(field, surface)| {
let (required, default, omitted) = match surface {
CanonicalSurfaceRef::GlobalOption { node, index } => {
let spec = &self.definition.commands.nodes[node].globals.options[index];
(
spec.required,
spec.default.clone(),
option_omitted_default(spec),
)
}
CanonicalSurfaceRef::BodyOption { index } => {
let spec = &body.options[index];
(
spec.required,
spec.default.clone(),
option_omitted_default(spec),
)
}
CanonicalSurfaceRef::BodyPositional { index } => {
let spec = &body.positionals.fixed[index];
(spec.required, spec.default.clone(), spec.default.clone())
}
CanonicalSurfaceRef::BodyTail => (
body.positionals.tail.as_ref().is_some_and(|s| s.min > 0),
None,
None,
),
CanonicalSurfaceRef::GlobalFlag { node, index } => {
let default = flag_default(
&self.definition.commands.nodes[node].globals.flags[index].shape,
);
(false, Some(default.clone()), Some(default))
}
CanonicalSurfaceRef::BodyFlag { index } => {
let default = flag_default(&body.flags[index].shape);
(false, Some(default.clone()), Some(default))
}
};
json_defaults.push(omitted);
let kind = match surface {
CanonicalSurfaceRef::GlobalOption { .. }
| CanonicalSurfaceRef::BodyOption { .. } => ToolArgumentKind::Option,
CanonicalSurfaceRef::GlobalFlag { .. }
| CanonicalSurfaceRef::BodyFlag { .. } => ToolArgumentKind::Flag,
CanonicalSurfaceRef::BodyPositional { .. } => ToolArgumentKind::Positional,
CanonicalSurfaceRef::BodyTail => ToolArgumentKind::Tail,
};
arguments.push(ToolArgument {
kind,
name: field.name.clone(),
aliases: field.aliases.clone(),
short: field.short,
schema: SchemaRef::with_root(self.schema.clone(), field.type_.clone()),
required,
default,
});
});
let mut node_index = 0;
let canonical_path = path
.iter()
.map(|part| {
node_index = self.definition.commands.nodes[node_index]
.subcommands
.iter()
.filter_map(|child| child.as_usize())
.find(|child| {
let node = &self.definition.commands.nodes[*child];
node.name == *part || node.aliases.contains(part)
})
.expect("resolved command path");
self.definition.commands.nodes[node_index].name.clone()
})
.collect();
Ok(ToolCommand {
tool: self.clone(),
index,
path: canonical_path,
input: SchemaRef::with_root(self.schema.clone(), input.record_schema.root.clone())
.with_root_field_defaults(json_defaults),
wire_input: input.record_schema,
arguments,
})
}
pub fn client(&self) -> ReflectedToolClient {
ReflectedToolClient { tool: self.clone() }
}
}
#[derive(Clone, Debug)]
pub struct ToolNode {
tool: ToolType,
index: usize,
path: Vec<String>,
}
impl ToolNode {
pub fn name(&self) -> &str {
&self.tool.definition.commands.nodes[self.index].name
}
pub fn path(&self) -> &[String] {
&self.path
}
pub fn aliases(&self) -> &[String] {
&self.tool.definition.commands.nodes[self.index].aliases
}
pub fn doc(&self) -> &Doc {
&self.tool.definition.commands.nodes[self.index].doc
}
pub fn is_callable(&self) -> bool {
self.tool.definition.commands.nodes[self.index]
.body
.is_some()
}
pub fn command(&self) -> Result<ToolCommand, ToolReflectionError> {
if !self.is_callable() {
return Err(ToolReflectionError::InvalidMetadata(format!(
"command namespace `{}` is not callable",
self.path.join(" ")
)));
}
let path = self.path.iter().map(String::as_str).collect::<Vec<_>>();
self.tool.command(&path)
}
pub fn children(&self) -> Vec<ToolNode> {
self.tool.definition.commands.nodes[self.index]
.subcommands
.iter()
.filter_map(|child| child.as_usize())
.map(|index| {
let mut path = self.path.clone();
path.push(self.tool.definition.commands.nodes[index].name.clone());
ToolNode {
tool: self.tool.clone(),
index,
path,
}
})
.collect()
}
}
pub fn get_all_tool_types() -> Result<Vec<ToolType>, ToolReflectionError> {
host::get_all_tools()
.into_iter()
.map(ToolType::from_registered)
.collect()
}
pub fn get_tool_type(name: &str) -> Result<ToolType, ToolReflectionError> {
ToolType::from_registered(
host::get_tool(name).ok_or_else(|| ToolReflectionError::NotFound(name.to_string()))?,
)
}
#[derive(Clone, Debug)]
pub struct ReflectedToolClient {
tool: ToolType,
}
impl ReflectedToolClient {
pub fn command(&self, path: &[&str]) -> Result<ToolCommand, ToolReflectionError> {
self.tool.command(path)
}
pub fn definition(&self) -> &ToolType {
&self.tool
}
}
#[derive(Clone, Debug)]
pub struct ToolCommand {
tool: ToolType,
index: usize,
path: Vec<String>,
input: SchemaRef,
wire_input: SchemaGraph,
arguments: Vec<ToolArgument>,
}
impl ToolCommand {
pub fn path(&self) -> &[String] {
&self.path
}
pub fn input_schema(&self) -> &SchemaRef {
&self.input
}
pub fn arguments(&self) -> &[ToolArgument] {
&self.arguments
}
pub fn body(&self) -> &CommandBody {
self.tool.definition.commands.nodes[self.index]
.body
.as_ref()
.expect("resolved body")
}
pub fn doc(&self) -> &Doc {
&self.tool.definition.commands.nodes[self.index].doc
}
pub fn output_schema(&self) -> Option<SchemaRef> {
self.body()
.result
.as_ref()
.map(|spec| SchemaRef::with_root(self.tool.schema.clone(), spec.type_.clone()))
}
pub fn validate_value(&self, value: &SchemaValue) -> Result<(), ToolReflectionError> {
self.input
.validate_value(value)
.map_err(ToolReflectionError::InvalidInput)?;
self.check_constraints(value)
}
#[cfg(feature = "json")]
pub fn pack_json(&self, input: &serde_json::Value) -> Result<SchemaValue, ToolReflectionError> {
let value = self
.input
.pack_json(input)
.map_err(ToolReflectionError::InvalidInput)?;
self.check_constraints(&value)?;
Ok(value)
}
fn checked_input(&self, value: SchemaValue) -> Result<TypedSchemaValue, ToolReflectionError> {
self.validate_value(&value)?;
Ok(TypedSchemaValue::new(self.wire_input.clone(), value))
}
fn check_constraints(&self, value: &SchemaValue) -> Result<(), ToolReflectionError> {
let SchemaValue::Record { fields } = value else {
unreachable!("validated record")
};
let present = |reference: &Ref| -> bool {
let name = match reference {
Ref::Present(name) => name,
Ref::ValueIs(item) => &item.name,
};
let index = self
.arguments
.iter()
.position(|argument| argument.name == *name || argument.aliases.contains(name))
.expect("validated constraint reference");
match reference {
Ref::Present(_) => value_present(
&fields[index],
self.arguments[index].default.as_ref(),
self.arguments[index].kind == ToolArgumentKind::Flag,
),
Ref::ValueIs(item) => value_matches(&fields[index], &item.value),
}
};
let quant = |refs: &[Ref], all: bool| -> bool {
if all {
refs.iter().all(&present)
} else {
refs.iter().any(&present)
}
};
for (index, constraint) in self.body().constraints.iter().enumerate() {
let okay = match constraint {
Constraint::RequiresAll(refs) => quant(refs, true),
Constraint::RequiresAny(refs) => quant(refs, false),
Constraint::AllOrNone(refs) => {
let count = refs.iter().filter(|r| present(r)).count();
count == 0 || count == refs.len()
}
Constraint::MutexGroups(groups) => {
groups.iter().filter(|g| quant(&g.refs, true)).count() <= 1
}
Constraint::Implies(c) => {
!quant(
&c.lhs,
matches!(c.lhs_quant, crate::schema::tool::Quantifier::All),
) || quant(
&c.rhs,
matches!(c.rhs_quant, crate::schema::tool::Quantifier::All),
)
}
Constraint::Forbids(c) => {
!quant(
&c.lhs,
matches!(c.lhs_quant, crate::schema::tool::Quantifier::All),
) || !quant(&c.rhs, false)
}
};
if !okay {
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput(format!("command constraint {index} failed")),
));
}
}
Ok(())
}
pub async fn invoke_value(
&self,
value: SchemaValue,
) -> Result<Option<SchemaValue>, ToolReflectionError> {
self.invoke_value_with_stdin(value, None).await
}
pub async fn invoke_value_with_stdin(
&self,
value: SchemaValue,
stdin: Option<InputStream>,
) -> Result<Option<SchemaValue>, ToolReflectionError> {
self.check_invocation_streams(stdin.is_some())?;
let input = self.checked_input(value)?;
self.invoke_checked_input(input, stdin).await
}
fn check_invocation_streams(&self, has_stdin: bool) -> Result<(), ToolReflectionError> {
if self
.body()
.stdout
.as_ref()
.is_some_and(|spec| spec.required)
{
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput(
"command requires caller-readable stdout".to_string(),
),
));
}
if self
.body()
.stderr
.as_ref()
.is_some_and(|spec| spec.required)
{
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput(
"command requires caller-readable stderr".to_string(),
),
));
}
if self.body().stdin.as_ref().is_some_and(|spec| spec.required) && !has_stdin {
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput("command requires stdin".to_string()),
));
}
Ok(())
}
async fn invoke_checked_input(
&self,
input: TypedSchemaValue,
stdin: Option<InputStream>,
) -> Result<Option<SchemaValue>, ToolReflectionError> {
let rpc = ToolRpc::create(self.tool.lookup_name()).map_err(|error| {
ToolReflectionError::Tool(tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
}))
})?;
let result = tool_client::invoke_and_await(
&rpc,
&self.path,
&input,
stdin.map(tool_client::pump_tool_stdin),
None,
None,
self.error_decoder(),
)
.await
.map_err(ToolReflectionError::Tool)?;
self.decode_result(result)
.map_err(ToolReflectionError::Tool)
}
#[cfg(feature = "json")]
pub async fn invoke_json(
&self,
input: &serde_json::Value,
) -> Result<Option<serde_json::Value>, ToolReflectionError> {
let value = self.pack_json(input)?;
self.check_invocation_streams(false)?;
let input = TypedSchemaValue::new(self.wire_input.clone(), value);
let result = self.invoke_checked_input(input, None).await?;
result
.map(|value| {
self.output_schema()
.expect("declared result")
.unpack_checked_json(&value)
.map_err(|error| {
ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(
error.to_string(),
))
})
})
.transpose()
}
pub async fn start_value(
&self,
value: SchemaValue,
stdin: Option<InputStream>,
) -> Result<ToolInvocation<Option<SchemaValue>, ReflectedToolCustomError>, ToolReflectionError>
{
if self.body().stdin.as_ref().is_some_and(|spec| spec.required) && stdin.is_none() {
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput("command requires stdin".to_string()),
));
}
let input = self.checked_input(value)?;
let rpc = ToolRpc::create(self.tool.lookup_name()).map_err(|error| {
ToolReflectionError::Tool(tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
}))
})?;
let command = self.clone();
tool_client::start_tool_invocation(
&rpc,
&self.path,
&input,
stdin,
self.body().stdout.is_some(),
self.body().stderr.is_some(),
move |result| command.decode_result(result),
self.error_decoder(),
)
.await
.map_err(ToolReflectionError::Tool)
}
pub fn trigger_value(
&self,
value: SchemaValue,
stdin: Option<InputStream>,
) -> Result<(), ToolReflectionError> {
if self
.body()
.stdout
.as_ref()
.is_some_and(|spec| spec.required)
{
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput(
"command requires caller-readable stdout".to_string(),
),
));
}
if self
.body()
.stderr
.as_ref()
.is_some_and(|spec| spec.required)
{
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput(
"command requires caller-readable stderr".to_string(),
),
));
}
if self.body().stdin.as_ref().is_some_and(|spec| spec.required) && stdin.is_none() {
return Err(ToolReflectionError::InvalidInput(
GolemReflectError::InvalidInput("command requires stdin".to_string()),
));
}
let input = self.checked_input(value)?;
let encoded = crate::encode_typed_schema_value(&input).map_err(|error| {
ToolReflectionError::InvalidInput(GolemReflectError::SchemaEncode(error.to_string()))
})?;
let rpc = ToolRpc::create(self.tool.lookup_name()).map_err(|error| {
ToolReflectionError::Tool(tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
}))
})?;
rpc.invoke(&self.path, encoded, stdin.map(tool_client::pump_tool_stdin))
.map_err(|error| {
ToolReflectionError::Tool(tool_client::map_rpc_error(error, &self.error_decoder()))
})
}
fn decode_result(
&self,
result: InvocationResult,
) -> Result<Option<SchemaValue>, ToolError<ReflectedToolCustomError>> {
match (self.output_schema(), result.result) {
(None, None) => Ok(None),
(Some(schema), Some(value))
if is_equivalent_cross_graph(
value.graph(),
&value.graph().root,
schema.graph(),
schema.root(),
) =>
{
schema
.validate_value(value.value())
.map_err(|error| ToolError::MalformedRemoteOutput(error.to_string()))?;
Ok(Some(value.into_parts().1))
}
_ => Err(ToolError::MalformedRemoteOutput(
"missing, unexpected, or mismatched structured result".to_string(),
)),
}
}
fn error_decoder(
&self,
) -> impl Fn(String, TypedSchemaValue) -> Result<Option<ReflectedToolCustomError>, String> + 'static
{
let body = self.body().clone();
let graph = self.tool.schema.clone();
move |name, payload| {
let Some(case) = body.errors.iter().find(|case| case.name == name) else {
return Ok(None);
};
match &case.payload {
Some(root) => {
if !is_equivalent_cross_graph(
payload.graph(),
&payload.graph().root,
&graph,
root,
) {
return Err(format!("custom error `{name}` has the wrong schema"));
}
let schema = SchemaRef::with_root(graph.clone(), root.clone());
schema
.validate_value(payload.value())
.map_err(|error| error.to_string())?;
}
None if !matches!(payload.value(), SchemaValue::Tuple { elements } if elements.is_empty()) =>
{
return Err(format!("custom error `{name}` has an unexpected payload"));
}
None => {}
}
Ok(Some(ReflectedToolCustomError { name, payload }))
}
}
}
#[derive(Clone, Debug)]
pub struct ToolClientCommandDefinition {
pub path: Arc<[String]>,
pub input: SchemaRef,
pub output: Option<SchemaRef>,
}
#[derive(Clone, Debug)]
pub struct ToolClientDefinitionBuilder {
name: Option<String>,
commands: Vec<ToolClientCommandDefinition>,
}
impl ToolClientDefinitionBuilder {
pub fn named(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
commands: Vec::new(),
}
}
pub fn unnamed() -> Self {
Self {
name: None,
commands: Vec::new(),
}
}
pub fn command<I, O, P, S>(mut self, path: P) -> Result<Self, ToolReflectionError>
where
I: crate::IntoSchema,
O: crate::IntoSchema,
P: IntoIterator<Item = S>,
S: Into<String>,
{
self.push::<I, P, S>(path, Some(schema_for::<O>()?))?;
Ok(self)
}
pub fn unit_command<I, P, S>(mut self, path: P) -> Result<Self, ToolReflectionError>
where
I: crate::IntoSchema,
P: IntoIterator<Item = S>,
S: Into<String>,
{
self.push::<I, P, S>(path, None)?;
Ok(self)
}
fn push<I, P, S>(
&mut self,
path: P,
output: Option<SchemaRef>,
) -> Result<(), ToolReflectionError>
where
I: crate::IntoSchema,
P: IntoIterator<Item = S>,
S: Into<String>,
{
let path = path.into_iter().map(Into::into).collect::<Vec<_>>();
if path.iter().any(String::is_empty) {
return Err(ToolReflectionError::InvalidMetadata(
"tool client command paths cannot contain empty segments".to_string(),
));
}
if self
.commands
.iter()
.any(|command| command.path.as_ref() == path)
{
return Err(ToolReflectionError::InvalidMetadata(format!(
"duplicate tool client command `{}`",
path.join(" ")
)));
}
self.commands.push(ToolClientCommandDefinition {
path: path.into(),
input: schema_for::<I>()?,
output,
});
Ok(())
}
pub fn build(self) -> Result<ToolClientDefinition, ToolReflectionError> {
if self.name.as_deref().is_some_and(str::is_empty) {
return Err(ToolReflectionError::InvalidMetadata(
"tool client name cannot be empty".to_string(),
));
}
Ok(ToolClientDefinition {
name: self.name,
commands: self.commands.into(),
})
}
}
fn schema_for<T: crate::IntoSchema>() -> Result<SchemaRef, ToolReflectionError> {
crate::schema::try_into_schema_graph::<T>()
.map(SchemaRef::new)
.map_err(|error| ToolReflectionError::InvalidMetadata(error.to_string()))
}
#[derive(Clone, Debug)]
pub struct ToolClientDefinition {
name: Option<String>,
commands: Arc<[ToolClientCommandDefinition]>,
}
impl ToolClientDefinition {
pub fn named(name: impl Into<String>) -> ToolClientDefinitionBuilder {
ToolClientDefinitionBuilder::named(name)
}
pub fn unnamed() -> ToolClientDefinitionBuilder {
ToolClientDefinitionBuilder::unnamed()
}
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn commands(&self) -> &[ToolClientCommandDefinition] {
&self.commands
}
pub fn client(&self) -> Result<TypedToolClient, ToolReflectionError> {
let name = self.name.as_deref().ok_or_else(|| {
ToolReflectionError::InvalidMetadata(
"a nameless tool client definition requires an explicit target".to_string(),
)
})?;
self.client_for(name)
}
pub fn client_for(&self, name: &str) -> Result<TypedToolClient, ToolReflectionError> {
if name.is_empty() {
return Err(ToolReflectionError::InvalidMetadata(
"tool client target name cannot be empty".to_string(),
));
}
let rpc = ToolRpc::create(name).map_err(|error| {
ToolReflectionError::Tool(tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
}))
})?;
Ok(TypedToolClient {
definition: self.clone(),
rpc: Rc::new(rpc),
})
}
}
#[derive(Clone)]
pub struct TypedToolClient {
definition: ToolClientDefinition,
rpc: Rc<ToolRpc>,
}
impl TypedToolClient {
pub fn command<I, O>(
&self,
path: &[&str],
) -> Result<TypedToolCommand<I, O>, ToolReflectionError>
where
I: crate::IntoSchema,
O: crate::IntoSchema + crate::FromSchema,
{
let definition = self.definition_for(path)?;
let output = definition.output.as_ref().ok_or_else(|| {
ToolReflectionError::InvalidMetadata(format!(
"tool client command `{}` declares no result",
path.join(" ")
))
})?;
ensure_schema::<I>(&definition.input, "input")?;
ensure_schema::<O>(output, "output")?;
Ok(TypedToolCommand {
definition,
rpc: self.rpc.clone(),
marker: PhantomData,
})
}
pub fn unit_command<I>(
&self,
path: &[&str],
) -> Result<TypedUnitToolCommand<I>, ToolReflectionError>
where
I: crate::IntoSchema,
{
let definition = self.definition_for(path)?;
if definition.output.is_some() {
return Err(ToolReflectionError::InvalidMetadata(format!(
"tool client command `{}` declares a result",
path.join(" ")
)));
}
ensure_schema::<I>(&definition.input, "input")?;
Ok(TypedUnitToolCommand {
definition,
rpc: self.rpc.clone(),
marker: PhantomData,
})
}
fn definition_for(
&self,
path: &[&str],
) -> Result<ToolClientCommandDefinition, ToolReflectionError> {
self.definition
.commands
.iter()
.find(|command| {
command.path.len() == path.len()
&& command
.path
.iter()
.zip(path)
.all(|(left, right)| left == right)
})
.cloned()
.ok_or_else(|| ToolReflectionError::NotFound(path.join(" ")))
}
}
fn ensure_schema<T: crate::IntoSchema>(
declared: &SchemaRef,
label: &str,
) -> Result<(), ToolReflectionError> {
let requested = schema_for::<T>()?;
if !is_equivalent_cross_graph(
declared.graph(),
declared.root(),
requested.graph(),
requested.root(),
) {
return Err(ToolReflectionError::InvalidMetadata(format!(
"requested {label} type does not match the caller-owned tool definition"
)));
}
Ok(())
}
pub struct TypedToolCommand<I, O> {
definition: ToolClientCommandDefinition,
rpc: Rc<ToolRpc>,
marker: PhantomData<fn(I) -> O>,
}
impl<I, O> TypedToolCommand<I, O>
where
I: crate::IntoSchema,
O: crate::FromSchema,
{
pub async fn invoke(&self, input: &I) -> Result<O, ToolReflectionError> {
let value = input.to_value();
self.definition
.input
.validate_value(&value)
.map_err(ToolReflectionError::InvalidInput)?;
let typed = TypedSchemaValue::new(self.definition.input.graph().clone(), value);
let result = tool_client::invoke_and_await(
self.rpc.as_ref(),
&self.definition.path,
&typed,
None,
None,
None,
|name, payload| Ok(Some(ReflectedToolCustomError { name, payload })),
)
.await
.map_err(ToolReflectionError::Tool)?;
let value = decode_caller_owned_result(&self.definition, result)?;
O::from_value(&value).map_err(|error| {
ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(error.to_string()))
})
}
}
pub struct TypedUnitToolCommand<I> {
definition: ToolClientCommandDefinition,
rpc: Rc<ToolRpc>,
marker: PhantomData<fn(I)>,
}
impl<I: crate::IntoSchema> TypedUnitToolCommand<I> {
pub async fn invoke(&self, input: &I) -> Result<(), ToolReflectionError> {
let value = input.to_value();
self.definition
.input
.validate_value(&value)
.map_err(ToolReflectionError::InvalidInput)?;
let typed = TypedSchemaValue::new(self.definition.input.graph().clone(), value);
let result = tool_client::invoke_and_await(
self.rpc.as_ref(),
&self.definition.path,
&typed,
None,
None,
None,
|name, payload| Ok(Some(ReflectedToolCustomError { name, payload })),
)
.await
.map_err(ToolReflectionError::Tool)?;
if result.result.is_some() {
return Err(ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(
"tool returned a value instead of unit".to_string(),
)));
}
Ok(())
}
}
fn decode_caller_owned_result(
definition: &ToolClientCommandDefinition,
result: InvocationResult,
) -> Result<SchemaValue, ToolReflectionError> {
let expected = definition.output.as_ref().expect("value command");
let value = result.result.ok_or_else(|| {
ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(
"tool returned unit instead of a value".to_string(),
))
})?;
if !is_equivalent_cross_graph(
value.graph(),
&value.graph().root,
expected.graph(),
expected.root(),
) {
return Err(ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(
"tool result schema does not match the caller-owned definition".to_string(),
)));
}
expected.validate_value(value.value()).map_err(|error| {
ToolReflectionError::Tool(ToolError::MalformedRemoteOutput(error.to_string()))
})?;
Ok(value.into_parts().1)
}
pub struct DynamicToolClient {
name: String,
}
impl DynamicToolClient {
pub fn new(name: impl Into<String>) -> Self {
Self { name: name.into() }
}
pub async fn invoke(
&self,
path: &[String],
input: &TypedSchemaValue,
) -> Result<InvocationResult, ToolError<ReflectedToolCustomError>> {
self.invoke_with_stdin(path, input, None).await
}
pub async fn invoke_with_stdin(
&self,
path: &[String],
input: &TypedSchemaValue,
stdin: Option<InputStream>,
) -> Result<InvocationResult, ToolError<ReflectedToolCustomError>> {
let rpc = ToolRpc::create(&self.name).map_err(|error| {
tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
})
})?;
tool_client::invoke_and_await(
&rpc,
path,
input,
stdin.map(tool_client::pump_tool_stdin),
None,
None,
|name, payload| Ok(Some(ReflectedToolCustomError { name, payload })),
)
.await
}
pub async fn start(
&self,
path: &[String],
input: &TypedSchemaValue,
stdin: Option<InputStream>,
attach_stdout: bool,
attach_stderr: bool,
) -> Result<
ToolInvocation<InvocationResult, ReflectedToolCustomError>,
ToolError<ReflectedToolCustomError>,
> {
let rpc = ToolRpc::create(&self.name).map_err(|error| {
tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
})
})?;
tool_client::start_tool_invocation(
&rpc,
path,
input,
stdin,
attach_stdout,
attach_stderr,
Ok,
|name, payload| Ok(Some(ReflectedToolCustomError { name, payload })),
)
.await
}
pub fn trigger(
&self,
path: &[String],
input: &TypedSchemaValue,
stdin: Option<InputStream>,
) -> Result<(), ToolError<ReflectedToolCustomError>> {
let encoded = crate::encode_typed_schema_value(input).map_err(|error| {
ToolError::Rpc(tool_client::RpcError::Protocol(format!(
"failed to encode tool input: {error}"
)))
})?;
let rpc = ToolRpc::create(&self.name).map_err(|error| {
tool_client::map_rpc_error(error, &|_, _| {
Ok::<Option<ReflectedToolCustomError>, String>(None)
})
})?;
rpc.invoke(path, encoded, stdin.map(tool_client::pump_tool_stdin))
.map_err(|error| {
tool_client::map_rpc_error(error, &|name, payload| {
Ok(Some(ReflectedToolCustomError { name, payload }))
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::IntoSchema;
use crate::schema::SchemaType;
use crate::schema::render::RenderError;
use crate::schema::tool::{
BoolFlagShape, CommandBody, CommandIndex, CommandNode, CommandTree, ErrorCase, ErrorKind,
FlagSpec, Globals, OptionShape, OptionSpec, Positional, Positionals, RepeatableListShape,
Repetition, ResultSpec, TailPositional, ValueIsRef,
};
use crate::schema::{NamedFieldType, SchemaTypeDef, TypeId};
use test_r::test;
fn sample() -> ToolType {
sample_with_outputs(false, false)
}
fn sample_with_outputs(stdout: bool, stderr: bool) -> ToolType {
let doc = Doc {
summary: String::new(),
description: String::new(),
examples: Vec::new(),
};
let body = CommandBody {
positionals: Positionals {
fixed: vec![Positional {
name: "message".to_string(),
doc: doc.clone(),
value_name: None,
type_: SchemaType::string(),
default: None,
required: true,
accepts_stdio: false,
}],
tail: None,
},
options: Vec::new(),
flags: Vec::new(),
constraints: Vec::new(),
stdin: None,
stdout: stdout.then(|| crate::schema::tool::StreamSpec {
doc: doc.clone(),
mime: Vec::new(),
required: true,
}),
stderr: stderr.then(|| crate::schema::tool::StreamSpec {
doc: doc.clone(),
mime: Vec::new(),
required: true,
}),
result: Some(ResultSpec {
type_: SchemaType::s32(),
doc: doc.clone(),
formatters: Vec::new(),
default_formatter: String::new(),
}),
errors: Vec::new(),
annotations: None,
};
let schema = Arc::new(SchemaGraph::empty());
ToolType {
lookup_name: "sample".to_string(),
definition: Arc::new(Tool {
version: "1".to_string(),
requires_filesystem: false,
commands: CommandTree {
nodes: vec![
CommandNode {
name: "sample".to_string(),
aliases: Vec::new(),
doc: doc.clone(),
globals: Globals::default(),
subcommands: vec![CommandIndex(1)],
body: None,
},
CommandNode {
name: "run".to_string(),
aliases: vec!["r".to_string()],
doc,
globals: Globals::default(),
subcommands: Vec::new(),
body: Some(body),
},
],
},
schema: (*schema).clone(),
}),
schema,
implemented_by: crate::ComponentId::new(crate::Uuid::nil()),
}
}
#[test]
fn alias_resolves_to_canonical_path_and_bad_input_is_local() {
let command = sample().command(&["r"]).expect("alias");
assert_eq!(command.path(), &["run"]);
assert_eq!(command.arguments()[0].name, "message");
assert!(
command
.checked_input(SchemaValue::Record {
fields: vec![SchemaValue::S32(1)]
})
.is_err()
);
}
#[test]
async fn scalar_and_trigger_calls_reject_each_required_output_before_rpc() {
for (stdout, stderr, expected) in [
(true, false, "command requires caller-readable stdout"),
(false, true, "command requires caller-readable stderr"),
] {
let command = sample_with_outputs(stdout, stderr)
.command(&["run"])
.expect("command");
let input = SchemaValue::Record {
fields: vec![SchemaValue::String("hello".to_string())],
};
assert_eq!(
command
.invoke_value(input.clone())
.await
.unwrap_err()
.to_string(),
format!("invalid tool input: invalid input: {expected}")
);
assert_eq!(
command.trigger_value(input, None).unwrap_err().to_string(),
format!("invalid tool input: invalid input: {expected}")
);
assert_eq!(
command
.invoke_json(&serde_json::json!({ "message": "hello" }))
.await
.unwrap_err()
.to_string(),
format!("invalid tool input: invalid input: {expected}")
);
assert!(matches!(
command
.invoke_json(&serde_json::json!({ "message": 42 }))
.await,
Err(ToolReflectionError::InvalidInput(
GolemReflectError::SchemaRender(_)
))
));
}
}
#[test]
fn namespace_nodes_are_discoverable_but_not_callable() {
let tool = sample();
let root = tool.root();
assert_eq!(root.name(), "sample");
assert!(!root.is_callable());
assert_eq!(root.children()[0].path(), &["run"]);
assert!(root.command().is_err());
let run = tool.node(&["r"]).expect("alias resolves to a node");
assert!(run.is_callable());
assert_eq!(run.path(), &["run"]);
assert_eq!(run.command().unwrap().path(), &["run"]);
}
#[derive(crate::IntoSchema)]
struct CallerResult {
value: String,
}
#[derive(crate::IntoSchema)]
struct RemoteResult {
value: String,
}
#[test]
fn caller_owned_results_compare_resolved_graphs() {
let definition = ToolClientCommandDefinition {
path: vec!["run".to_string()].into(),
input: SchemaRef::new(SchemaGraph::empty()),
output: Some(schema_for::<CallerResult>().unwrap()),
};
let graph = crate::schema::try_into_schema_graph::<RemoteResult>().unwrap();
let result = InvocationResult {
result: Some(TypedSchemaValue::new(
graph,
RemoteResult {
value: "ok".to_string(),
}
.to_value(),
)),
};
assert_eq!(
decode_caller_owned_result(&definition, result).unwrap(),
CallerResult {
value: "ok".to_string(),
}
.to_value()
);
}
#[test]
fn caller_owned_definition_rejects_duplicate_paths() {
let builder = ToolClientDefinition::unnamed()
.unit_command::<String, _, _>(Vec::<String>::new())
.unwrap()
.unit_command::<String, _, _>(["run"])
.unwrap();
assert!(
builder
.clone()
.unit_command::<String, _, _>(Vec::<String>::new())
.is_err()
);
assert!(builder.unit_command::<String, _, _>(["run"]).is_err());
assert!(
ToolClientDefinition::unnamed()
.unit_command::<String, _, _>([""])
.is_err()
);
}
#[test]
fn optional_inputs_use_the_canonical_wire_schema() {
let mut tool = sample();
let definition = Arc::make_mut(&mut tool.definition);
let body = definition.commands.nodes[1].body.as_mut().unwrap();
body.positionals.fixed[0].required = false;
body.options.push(OptionSpec {
long: "mode".to_string(),
short: None,
aliases: Vec::new(),
doc: Doc::default(),
value_name: None,
shape: OptionShape::Scalar(SchemaType::string()),
default: None,
required: false,
env_var: None,
});
let command = tool.command(&["run"]).unwrap();
assert_eq!(command.input_schema().root(), &command.wire_input.root);
for fields in [
vec![
SchemaValue::Option { inner: None },
SchemaValue::Option { inner: None },
],
vec![
SchemaValue::Option {
inner: Some(Box::new(SchemaValue::String("hello".to_string()))),
},
SchemaValue::Option {
inner: Some(Box::new(SchemaValue::String("fast".to_string()))),
},
],
] {
let value = SchemaValue::Record { fields };
assert!(command.checked_input(value).is_ok());
}
}
#[test]
fn repeated_command_lookup_shares_the_discovered_definition_pool() {
let tool = sample();
let first = tool.command(&["run"]).unwrap();
let second = tool.command(&["r"]).unwrap();
assert!(
first
.input_schema()
.shares_definition_pool_with(second.input_schema())
);
assert!(
first
.input_schema()
.shares_definition_pool_with(&first.arguments()[0].schema)
);
assert!(
first
.input_schema()
.shares_definition_pool_with(&first.output_schema().unwrap())
);
assert!(
!first
.input_schema()
.shares_definition_pool_with(&SchemaRef::new(first.wire_input.clone()))
);
}
#[test]
fn malformed_remote_output_is_distinct() {
let command = sample().command(&["run"]).expect("command");
assert!(matches!(
command.decode_result(InvocationResult { result: None }),
Err(ToolError::MalformedRemoteOutput(_))
));
assert!(matches!(
command.decode_result(InvocationResult {
result: Some(TypedSchemaValue::new(
SchemaGraph {
defs: Vec::new(),
root: SchemaType::string()
},
SchemaValue::String("wrong".to_string()),
)),
}),
Err(ToolError::MalformedRemoteOutput(_))
));
}
fn named_record_graph(id: &str, field: &str) -> SchemaGraph {
SchemaGraph {
defs: vec![SchemaTypeDef {
id: TypeId::new(id),
name: None,
body: SchemaType::record(vec![NamedFieldType {
name: field.to_string(),
body: SchemaType::string(),
metadata: Default::default(),
}]),
}],
root: SchemaType::ref_to(TypeId::new(id)),
}
}
#[test]
fn reflected_result_checks_definitions_behind_identical_reference_ids() {
let mut tool = sample();
let expected = named_record_graph("example.Result", "old_field");
let definition = Arc::make_mut(&mut tool.definition);
definition.schema = expected.clone();
definition.commands.nodes[1]
.body
.as_mut()
.unwrap()
.result
.as_mut()
.unwrap()
.type_ = expected.root.clone();
tool.schema = Arc::new(expected);
let command = tool.command(&["run"]).unwrap();
let remote_value = SchemaValue::Record {
fields: vec![SchemaValue::String("value".to_string())],
};
assert!(matches!(
command.decode_result(InvocationResult {
result: Some(TypedSchemaValue::new(
named_record_graph("example.Result", "new_field"),
remote_value.clone(),
)),
}),
Err(ToolError::MalformedRemoteOutput(_))
));
assert_eq!(
command
.decode_result(InvocationResult {
result: Some(TypedSchemaValue::new(
named_record_graph("remote.Result", "old_field"),
remote_value.clone(),
)),
})
.unwrap(),
Some(remote_value)
);
}
#[test]
fn reflected_custom_error_checks_definitions_behind_identical_reference_ids() {
let mut tool = sample();
let expected = named_record_graph("example.Error", "old_field");
let definition = Arc::make_mut(&mut tool.definition);
definition.schema = expected.clone();
definition.commands.nodes[1]
.body
.as_mut()
.unwrap()
.errors
.push(ErrorCase {
name: "bad".to_string(),
doc: Doc::default(),
kind: ErrorKind::RuntimeError,
exit_code: 1,
payload: Some(expected.root.clone()),
});
tool.schema = Arc::new(expected);
let command = tool.command(&["run"]).unwrap();
let remote_value = SchemaValue::Record {
fields: vec![SchemaValue::String("value".to_string())],
};
let decode = command.error_decoder();
assert!(
decode(
"bad".to_string(),
TypedSchemaValue::new(
named_record_graph("example.Error", "new_field"),
remote_value.clone(),
),
)
.is_err()
);
assert!(
decode(
"bad".to_string(),
TypedSchemaValue::new(
named_record_graph("remote.Error", "old_field"),
remote_value
),
)
.unwrap()
.is_some()
);
}
#[test]
async fn constraints_use_declared_flag_default_and_nested_value_is() {
let mut tool = sample();
let definition = Arc::make_mut(&mut tool.definition);
let body = definition.commands.nodes[1].body.as_mut().unwrap();
body.options.push(OptionSpec {
long: "mode".to_string(),
short: None,
aliases: Vec::new(),
doc: Doc::default(),
value_name: None,
shape: OptionShape::Scalar(SchemaType::string()),
default: None,
required: false,
env_var: None,
});
body.flags.push(FlagSpec {
long: "enabled".to_string(),
short: None,
aliases: Vec::new(),
doc: Doc::default(),
shape: FlagShape::BoolFlag(BoolFlagShape {
default: true,
negatable: true,
}),
env_var: None,
});
body.constraints.push(Constraint::RequiresAll(vec![
Ref::Present("enabled".to_string()),
Ref::ValueIs(ValueIsRef {
name: "mode".to_string(),
value: SchemaValue::String("fast".to_string()),
}),
]));
let command = tool.command(&["run"]).unwrap();
assert_eq!(
command.arguments()[2].default,
Some(SchemaValue::Bool(true))
);
let input = |enabled| SchemaValue::Record {
fields: vec![
SchemaValue::String("hello".to_string()),
SchemaValue::Option {
inner: Some(Box::new(SchemaValue::String("fast".to_string()))),
},
SchemaValue::Bool(enabled),
],
};
assert!(command.validate_value(&input(false)).is_ok());
assert!(command.validate_value(&input(true)).is_err());
#[cfg(feature = "json")]
{
assert!(
command
.pack_json(&serde_json::json!({
"message": "hello",
"mode": "fast",
"enabled": false,
}))
.is_ok()
);
assert!(
command
.pack_json(&serde_json::json!({
"message": "hello",
"mode": "fast",
"enabled": true,
}))
.is_err()
);
assert!(
command
.pack_json(&serde_json::json!({
"message": "hello",
"mode": "fast",
}))
.is_err()
);
let error = command
.invoke_json(&serde_json::json!({
"message": "hello",
"mode": "fast",
"enabled": true,
}))
.await
.unwrap_err();
assert_eq!(
error.to_string(),
"invalid tool input: invalid input: command constraint 0 failed"
);
}
}
fn option_spec(long: &str, shape: OptionShape, default: Option<SchemaValue>) -> OptionSpec {
OptionSpec {
long: long.to_string(),
short: None,
aliases: Vec::new(),
doc: Doc::default(),
value_name: None,
shape,
default,
required: false,
env_var: None,
}
}
fn flag_spec(long: &str, shape: FlagShape) -> FlagSpec {
FlagSpec {
long: long.to_string(),
short: None,
aliases: Vec::new(),
doc: Doc::default(),
shape,
env_var: None,
}
}
fn some(value: SchemaValue) -> SchemaValue {
SchemaValue::Option {
inner: Some(Box::new(value)),
}
}
fn bash_shaped() -> ToolType {
let mut tool = sample();
let definition = Arc::make_mut(&mut tool.definition);
let body = definition.commands.nodes[1].body.as_mut().unwrap();
body.positionals.fixed[0].name = "script".to_string();
body.options = vec![
option_spec(
"cwd",
OptionShape::Scalar(SchemaType::string()),
Some(SchemaValue::String(String::new())),
),
option_spec(
"timeout",
OptionShape::Scalar(SchemaType::u32()),
Some(SchemaValue::U32(600)),
),
option_spec(
"shell",
OptionShape::Scalar(SchemaType::option(SchemaType::string())),
Some(some(SchemaValue::String("sh".to_string()))),
),
option_spec("label", OptionShape::Scalar(SchemaType::string()), None),
];
body.flags = vec![
flag_spec(
"login",
FlagShape::BoolFlag(BoolFlagShape {
default: false,
negatable: true,
}),
),
flag_spec("verbose", FlagShape::CountFlag(None)),
];
tool
}
fn bash_record(
script: &str,
cwd: &str,
timeout: u32,
shell: SchemaValue,
label: SchemaValue,
login: bool,
verbose: u32,
) -> SchemaValue {
SchemaValue::Record {
fields: vec![
SchemaValue::String(script.to_string()),
SchemaValue::String(cwd.to_string()),
SchemaValue::U32(timeout),
shell,
label,
SchemaValue::Bool(login),
SchemaValue::U32(verbose),
],
}
}
#[test]
fn json_packing_fills_omitted_declared_defaults() {
let command = bash_shaped().command(&["run"]).unwrap();
assert_eq!(
command
.arguments()
.iter()
.map(|argument| argument.name.as_str())
.collect::<Vec<_>>(),
[
"script", "cwd", "timeout", "shell", "label", "login", "verbose"
]
);
let sh = || some(SchemaValue::String("sh".to_string()));
let none = || SchemaValue::Option { inner: None };
assert_eq!(
command
.pack_json(&serde_json::json!({ "script": "echo ok" }))
.unwrap(),
bash_record("echo ok", "", 600, sh(), none(), false, 0)
);
assert_eq!(
command
.input_schema()
.validate_json(&serde_json::json!({ "script": "echo ok" }))
.unwrap(),
bash_record("echo ok", "", 600, sh(), none(), false, 0)
);
assert_eq!(
command
.pack_json(&serde_json::json!({
"script": "echo ok",
"cwd": "/tmp",
"timeout": 30,
"shell": null,
"label": "build",
"login": true,
"verbose": 2,
}))
.unwrap(),
bash_record(
"echo ok",
"/tmp",
30,
none(),
some(SchemaValue::String("build".to_string())),
true,
2,
)
);
assert_eq!(
command
.pack_json(&serde_json::json!({ "script": "sleep 1", "timeout": 120000 }))
.unwrap(),
bash_record("sleep 1", "", 120000, sh(), none(), false, 0)
);
assert!(matches!(
command.pack_json(&serde_json::json!({ "script": "echo ok", "unknown": 1 })),
Err(ToolReflectionError::InvalidInput(
GolemReflectError::SchemaRender(RenderError::UnexpectedField { field, .. })
)) if field == "unknown"
));
assert!(matches!(
command.pack_json(&serde_json::json!({ "script": "echo ok", "timeout": "soon" })),
Err(ToolReflectionError::InvalidInput(
GolemReflectError::SchemaRender(_)
))
));
}
#[test]
async fn json_calls_report_the_missing_required_field() {
let command = bash_shaped().command(&["run"]).unwrap();
let expected = "invalid tool input: schema rendering failed: value mismatch at $: \
missing record field `script`";
let error = command
.pack_json(&serde_json::json!({ "timeout": 30 }))
.unwrap_err();
assert!(matches!(
&error,
ToolReflectionError::InvalidInput(GolemReflectError::SchemaRender(
RenderError::ValueMismatch { path, reason }
)) if path == "$" && reason == "missing record field `script`"
));
assert_eq!(error.to_string(), expected);
assert_eq!(
command
.invoke_json(&serde_json::json!({}))
.await
.unwrap_err()
.to_string(),
expected
);
}
#[test]
fn json_schema_marks_defaulted_arguments_optional_with_their_default() {
let command = bash_shaped().command(&["run"]).unwrap();
let schema = command.input_schema().to_json_schema(false);
assert_eq!(schema["required"], serde_json::json!(["script"]));
let properties = &schema["properties"];
assert_eq!(properties["cwd"]["default"], serde_json::json!(""));
assert_eq!(properties["timeout"]["default"], serde_json::json!(600));
assert_eq!(properties["shell"]["default"], serde_json::json!("sh"));
assert_eq!(properties["login"]["default"], serde_json::json!(false));
assert_eq!(properties["verbose"]["default"], serde_json::json!(0));
assert!(properties["script"].get("default").is_none());
assert!(properties["label"].get("default").is_none());
let mut stripped = schema.clone();
for property in stripped["properties"].as_object_mut().unwrap().values_mut() {
property.as_object_mut().unwrap().remove("default");
}
let mut plain = SchemaRef::with_root(
command.tool.schema.clone(),
command.input_schema().root().clone(),
)
.to_json_schema(false);
assert_eq!(
plain["required"],
serde_json::json!(["script", "cwd", "timeout", "login", "verbose"])
);
plain["required"] = serde_json::json!(["script"]);
assert_eq!(stripped, plain);
assert_eq!(
command.input_schema().to_json_schema(true)["required"],
serde_json::json!(["script"])
);
}
#[test]
fn json_defaults_leave_the_wire_record_unchanged() {
let tool = bash_shaped();
let command = tool.command(&["run"]).unwrap();
let index = command.index;
assert_eq!(
command.wire_input,
tool.definition
.canonical_input_record_schema(index)
.unwrap()
);
assert_eq!(command.input_schema().root(), &command.wire_input.root);
let SchemaType::Record { fields, .. } = &command.wire_input.root else {
panic!("record root");
};
assert_eq!(
fields
.iter()
.map(|field| field.body.clone())
.collect::<Vec<_>>(),
vec![
SchemaType::string(),
SchemaType::string(),
SchemaType::u32(),
SchemaType::option(SchemaType::string()),
SchemaType::option(SchemaType::string()),
SchemaType::bool(),
SchemaType::u32(),
]
);
let packed = command
.pack_json(&serde_json::json!({ "script": "echo ok" }))
.unwrap();
assert!(command.checked_input(packed).is_ok());
assert!(
command
.validate_value(&SchemaValue::Record {
fields: vec![SchemaValue::String("echo ok".to_string())],
})
.is_err()
);
}
#[test]
fn json_defaults_skip_repeatable_options_and_tails() {
let mut tool = bash_shaped();
let definition = Arc::make_mut(&mut tool.definition);
let body = definition.commands.nodes[1].body.as_mut().unwrap();
body.options.push(option_spec(
"env",
OptionShape::RepeatableList(RepeatableListShape {
repetition: Repetition::Repeated,
item_type: SchemaType::string(),
}),
Some(SchemaValue::List {
elements: vec![SchemaValue::String("A=1".to_string())],
}),
));
body.positionals.tail = Some(TailPositional {
name: "args".to_string(),
doc: Doc::default(),
value_name: None,
item_type: SchemaType::string(),
min: 0,
max: None,
separator: None,
verbatim: false,
accepts_stdio: false,
});
let command = tool.command(&["run"]).unwrap();
let env = command
.arguments()
.iter()
.find(|argument| argument.name == "env")
.unwrap();
assert_eq!(
env.default,
Some(SchemaValue::List {
elements: vec![SchemaValue::String("A=1".to_string())],
})
);
for (json, missing) in [
(
serde_json::json!({ "script": "echo ok", "args": [] }),
"env",
),
(
serde_json::json!({ "script": "echo ok", "env": [] }),
"args",
),
] {
assert!(matches!(
command.pack_json(&json),
Err(ToolReflectionError::InvalidInput(GolemReflectError::SchemaRender(
RenderError::ValueMismatch { reason, .. }
))) if reason == format!("missing record field `{missing}`")
));
}
let schema = command.input_schema().to_json_schema(false);
assert_eq!(
schema["required"],
serde_json::json!(["script", "args", "env"])
);
assert!(schema["properties"]["env"].get("default").is_none());
assert!(schema["properties"]["args"].get("default").is_none());
}
}