use crate::canonical::ToolActionId;
use crate::id::{ExchangeId, SessionKey, ToolId, ToolName, TransactionId};
use crate::limits::ToolLimits;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use thiserror::Error;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JsonSchema {
schema: serde_json::Value,
}
impl JsonSchema {
pub fn try_new(schema: serde_json::Value) -> Result<Self, ToolContractError> {
if !schema.is_object() {
return Err(ToolContractError::SchemaNotObject);
}
Ok(Self { schema })
}
pub fn as_value(&self) -> &serde_json::Value {
&self.schema
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ToolSuccessContract {
Json {
schema: JsonSchema,
},
Text {
media_type: String,
},
}
impl ToolSuccessContract {
pub fn json(schema: JsonSchema) -> Self {
Self::Json { schema }
}
pub fn text(media_type: impl Into<String>) -> Result<Self, ToolContractError> {
let media_type = media_type.into();
if media_type.is_empty()
|| media_type.len() > 128
|| media_type.chars().any(|c| c.is_control())
{
return Err(ToolContractError::InvalidMediaType);
}
Ok(Self::Text { media_type })
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolOutputContract {
pub success: ToolSuccessContract,
pub error_data_schema: Option<JsonSchema>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolCancellationPolicy {
Cooperative {
grace: Duration,
},
Abortable,
IsolatedKillable {
grace: Duration,
},
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolSpec {
pub id: ToolId,
pub name: ToolName,
pub description: String,
pub input_schema: JsonSchema,
pub output_contract: ToolOutputContract,
pub limits: ToolLimits,
pub cancellation: ToolCancellationPolicy,
}
impl ToolSpec {
pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
pub fn try_new(
id: ToolId,
name: ToolName,
description: impl Into<String>,
input_schema: JsonSchema,
output_contract: ToolOutputContract,
limits: ToolLimits,
cancellation: ToolCancellationPolicy,
) -> Result<Self, ToolContractError> {
let description = description.into();
if description.len() > Self::MAX_DESCRIPTION_BYTES {
return Err(ToolContractError::DescriptionTooLong);
}
if description.chars().any(|c| c.is_control()) {
return Err(ToolContractError::ControlCharacter);
}
if limits.max_concurrent == 0
|| limits.max_input_bytes == 0
|| limits.max_output_bytes == 0
|| limits.execution_deadline.is_zero()
{
return Err(ToolContractError::InvalidLimits);
}
match &cancellation {
ToolCancellationPolicy::Cooperative { grace }
| ToolCancellationPolicy::IsolatedKillable { grace } => {
if grace.is_zero() {
return Err(ToolContractError::InvalidCancellationGrace);
}
}
ToolCancellationPolicy::Abortable => {}
}
Ok(Self {
id,
name,
description,
input_schema,
output_contract,
limits,
cancellation,
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
pub tool_name: ToolName,
pub tool_id: ToolId,
pub provider_tool_call_id: String,
pub arguments: serde_json::Value,
pub request_ordinal: u32,
}
#[derive(Clone, Debug)]
pub struct ToolCallContext {
pub transaction_id: TransactionId,
pub session_key: SessionKey,
pub exchange_id: Option<ExchangeId>,
pub tool_action_id: ToolActionId,
pub tool_id: ToolId,
pub deadline: Instant,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CanonicalToolOutput {
Json(serde_json::Value),
Text(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalToolError {
pub code: String,
pub message: String,
pub data: Option<serde_json::Value>,
}
impl CanonicalToolError {
pub fn try_new(
code: impl Into<String>,
message: impl Into<String>,
data: Option<serde_json::Value>,
max_message_bytes: usize,
) -> Result<Self, ToolContractError> {
let code = code.into();
let message = message.into();
if code.is_empty() || code.len() > 64 || code.chars().any(|c| c.is_control()) {
return Err(ToolContractError::InvalidErrorCode);
}
if message.is_empty()
|| message.len() > max_message_bytes
|| message.chars().any(|c| c.is_control())
{
return Err(ToolContractError::InvalidErrorMessage);
}
Ok(Self {
code,
message,
data,
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CanonicalToolResultOutcome {
Succeeded(CanonicalToolOutput),
DomainFailed(CanonicalToolError),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalToolResult {
pub transaction_id: TransactionId,
pub session_key: SessionKey,
pub exchange_id: ExchangeId,
pub tool_action_id: ToolActionId,
pub tool_id: ToolId,
pub provider_tool_call_id: String,
pub request_ordinal: u32,
pub outcome: CanonicalToolResultOutcome,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ToolLifecycleEvent {
Started {
tool_action_id: ToolActionId,
tool_id: ToolId,
tool_name: ToolName,
provider_tool_call_id: String,
request_ordinal: u32,
},
Completed {
result: CanonicalToolResult,
},
RuntimeFailed {
tool_action_id: ToolActionId,
tool_id: ToolId,
code: String,
},
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolContractError {
#[error("JSON schema must be an object")]
SchemaNotObject,
#[error("tool description exceeds maximum length")]
DescriptionTooLong,
#[error("tool string must not contain control characters")]
ControlCharacter,
#[error("tool limits must be non-zero")]
InvalidLimits,
#[error("cancellation grace must be non-zero")]
InvalidCancellationGrace,
#[error("invalid media type")]
InvalidMediaType,
#[error("invalid tool error code")]
InvalidErrorCode,
#[error("invalid tool error message")]
InvalidErrorMessage,
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolStartError {
#[error("tool capacity exceeded")]
CapacityExceeded,
#[error("tool start rejected: {0}")]
Rejected(&'static str),
}
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolRuntimeError {
#[error("tool panicked")]
Panicked,
#[error("tool completion lost")]
CompletionLost,
#[error("tool output contract violated")]
OutputContractViolated,
#[error("tool termination failed")]
TerminationFailed,
#[error("tool deadline exceeded")]
DeadlineExceeded,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ToolCompletion {
Succeeded(CanonicalToolOutput),
DomainFailed(CanonicalToolError),
RuntimeFailed(ToolRuntimeError),
}
#[cfg(test)]
mod tests {
use super::*;
use crate::id::{ChannelId, SessionId};
#[test]
fn tool_spec_construction() {
let schema = JsonSchema::try_new(serde_json::json!({
"type": "object",
"properties": { "q": { "type": "string" } }
}))
.unwrap();
let out = ToolOutputContract {
success: ToolSuccessContract::json(schema.clone()),
error_data_schema: None,
};
let spec = ToolSpec::try_new(
ToolId::try_new("search").unwrap(),
ToolName::try_new("search").unwrap(),
"Search the workspace",
schema,
out,
ToolLimits::default(),
ToolCancellationPolicy::Abortable,
)
.unwrap();
assert_eq!(spec.id.as_str(), "search");
}
#[test]
fn schema_must_be_object() {
assert!(JsonSchema::try_new(serde_json::json!([])).is_err());
}
#[test]
fn lifecycle_result_serializes() {
let tid = TransactionId::generate();
let sk = SessionKey::new(
ChannelId::try_new("ch").unwrap(),
SessionId::try_new("s").unwrap(),
);
let result = CanonicalToolResult {
transaction_id: tid,
session_key: sk,
exchange_id: ExchangeId::generate(),
tool_action_id: ToolActionId::new("a1"),
tool_id: ToolId::try_new("t").unwrap(),
provider_tool_call_id: "p1".into(),
request_ordinal: 0,
outcome: CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text("ok".into())),
};
let ev = ToolLifecycleEvent::Completed { result };
let json = serde_json::to_string(&ev).unwrap();
let _back: ToolLifecycleEvent = serde_json::from_str(&json).unwrap();
}
}