Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions crates/agentic-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod executor;
pub mod proxy;
pub mod readiness;
pub mod storage;
pub mod tool;
pub mod types;
pub mod utils;

Expand All @@ -13,10 +14,14 @@ pub use storage::{
SchemaManager, StorageError, StoreResult, create_pool, create_pool_with_schema,
models::{Conversation as DbConversation, Item as DbItem, Response as DbResponse},
};
pub use tool::{
FunctionHandler, GatewayExecutor, ToolEntry, ToolError, ToolHandler, ToolOutput, ToolRegistry, ToolType,
};
pub use types::{
FunctionTool, FunctionToolCall, FunctionToolResultMessage, IncompleteDetails, InputContent, InputImageContent,
InputItem, InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, OutputItem, OutputMessage,
OutputTextContent, OutputTokenDetails, ReasoningOutput, ReasoningTextContent, RequestPayload, ResponsePayload,
ResponseUsage, ResponsesInput, ResponsesTool, ToolChoice, UpstreamRequest,
CodeInterpreterToolParam, EmptyToolNameError, FileSearchToolParam, FunctionTool, FunctionToolCall,
FunctionToolParam, FunctionToolResultMessage, IncompleteDetails, InputContent, InputImageContent, InputItem,
InputMessage, InputMessageContent, InputTextContent, InputTokenDetails, McpToolParam, NonEmptyToolName, OutputItem,
OutputMessage, OutputTextContent, OutputTokenDetails, ReasoningOutput, ReasoningTextContent, RequestPayload,
ResponsePayload, ResponseUsage, ResponsesInput, ResponsesTool, ToolChoice, UpstreamRequest, WebSearchToolParam,
};
pub use utils::{utcnow_str, uuid7_str};
3 changes: 2 additions & 1 deletion crates/agentic-core/src/storage/types/response.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ use serde::{Deserialize, Serialize};

use super::super::models::Response as StorageDbResponse;
use super::errors::StorageError;
use crate::types::io::{ResponsesTool, ToolChoice};
use crate::types::io::ToolChoice;
use crate::types::tools::ResponsesTool;
use crate::utils::common::serialize_to_string;

/// Response metadata with effective configuration.
Expand Down
54 changes: 54 additions & 0 deletions crates/agentic-core/src/tool/function.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
use serde_json::Value;

use crate::types::io::FunctionTool;
use crate::types::tools::FunctionToolParam;

use super::handler::{ToolError, ToolHandler};
use super::registry::ToolType;

impl From<&FunctionToolParam> for FunctionTool {
fn from(p: &FunctionToolParam) -> Self {
Self {
type_: "function".to_owned(),
name: p.name.as_str().to_owned(),
description: p.description.clone(),
parameters: p.parameters.clone(),
strict: p.strict,
}
}
}

/// Handler for `type: "function"` tools.
///
/// Function tools are client-owned: the gateway normalises them for vLLM but
/// never executes them. `FunctionHandler` intentionally implements only
/// [`ToolHandler`], not [`super::handler::GatewayExecutor`] — the type system
/// makes it impossible to call `execute()` on a client-owned tool.
#[derive(Debug)]
pub struct FunctionHandler;

impl ToolHandler for FunctionHandler {
fn tool_type(&self) -> ToolType {
ToolType::Function
}

fn validate(&self, param: &Value) -> Result<(), ToolError> {
match param.get("name").and_then(Value::as_str) {
Some(name) if !name.is_empty() => Ok(()),
_ => Err(ToolError::Config("function tool must have a non-empty name".into())),
}
}

fn normalize(&self, param: &Value) -> Vec<FunctionTool> {
// Deserialize into the typed struct so From<&FunctionToolParam> is the single
// conversion path. name is NonEmptyToolName so serde rejects empty names;
// any remaining deserialize error means validate() was not called first.
match serde_json::from_value::<FunctionToolParam>(param.clone()) {
Ok(p) => vec![FunctionTool::from(&p)],
Err(e) => {
tracing::warn!("normalize() called with invalid param: {e} — validate() must be called first");
vec![]
}
}
}
}
85 changes: 85 additions & 0 deletions crates/agentic-core/src/tool/handler.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use std::future::Future;
use std::pin::Pin;

use serde_json::Value;

use crate::types::io::FunctionTool;

#[derive(Debug, Clone)]
pub struct ToolOutput {
pub call_id: String,
pub output: String,
}

#[derive(Debug, thiserror::Error)]
pub enum ToolError {
#[error("execution failed: {0}")]
Execution(String),
#[error("invalid tool config: {0}")]
Config(String),
}

/// Trait implemented by every tool type — client-owned and gateway-owned alike.
///
/// Covers validation and normalization: the steps that apply to all tools
/// regardless of who executes them.
///
/// Implementations must be `Send + Sync` so they can be stored behind `Arc<dyn
/// ToolHandler>` and used across async task boundaries.
pub trait ToolHandler: Send + Sync {
#[must_use]
fn tool_type(&self) -> super::registry::ToolType;

/// Validate the tool param JSON.
///
/// # Errors
///
/// Returns [`ToolError::Config`] for obviously invalid configurations.
fn validate(&self, param: &Value) -> Result<(), ToolError>;

/// Normalise this tool declaration into vLLM-compatible `FunctionTool` entries.
#[must_use]
fn normalize(&self, param: &Value) -> Vec<FunctionTool>;
}

/// Extension of [`ToolHandler`] for tool types that are executed by the gateway.
///
/// Only gateway-owned tools (`Mcp`, `WebSearch`, `FileSearch`, `CodeInterpreter`)
/// implement this trait. Client-owned tools (`Function`) do not — the type system
/// makes it impossible to call `execute()` on them.
///
/// ## Note on `async fn` in traits
///
/// Native `async fn` in traits (Rust 1.75+) is not yet `dyn`-compatible. Since
/// PR B will store handlers as `Arc<dyn GatewayExecutor>`, we use explicit
/// `Pin<Box<dyn Future>>` return types.
pub trait GatewayExecutor: ToolHandler + 'static {
/// Execute a tool call and return the result.
///
/// ## `config` parameter
///
/// `config` is the serialised **server-level** tool param (i.e. the `*ToolParam`
/// struct stored in [`super::registry::ToolEntry::config`]). It is **not** the
/// per-tool parameter schema.
///
/// # Errors
///
/// Returns [`ToolError::Execution`] if the tool call fails.
fn execute(
&self,
tool_name: &str,
arguments: &str,
config: &Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, ToolError>> + Send + '_>>;
}

#[cfg(test)]
mod tests {
use std::sync::Arc;

use super::*;

// Compile-time check: Arc<dyn GatewayExecutor> must be constructable.
// This fails to compile if GatewayExecutor ever becomes dyn-incompatible.
fn _assert_gateway_executor_dyn_compatible(_: Arc<dyn GatewayExecutor>) {}
}
13 changes: 13 additions & 0 deletions crates/agentic-core/src/tool/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Tool framework — registry, handler trait, and normalization pipeline.
//!
//! Wire format types (`ResponsesTool`, param structs) live in [`crate::types::tools`].
//! This module owns the behavioral layer: routing, handler interface, and normalization.

pub mod function;
pub mod handler;
pub mod normalize;
pub mod registry;

pub use function::FunctionHandler;
pub use handler::{GatewayExecutor, ToolError, ToolHandler, ToolOutput};
pub use registry::{ToolEntry, ToolRegistry, ToolType};
53 changes: 53 additions & 0 deletions crates/agentic-core/src/tool/normalize.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
use crate::types::io::FunctionTool;
use crate::types::io::input::FunctionToolResultMessage;
use crate::types::tools::ResponsesTool;

use super::handler::ToolOutput;

impl ResponsesTool {
/// Normalise this tool declaration to the `FunctionTool` wire format that vLLM understands.
///
/// - `Function` variants convert via [`From<&FunctionToolParam>`] for `FunctionTool`.
/// Returns `None` and logs at `debug` level if the name is empty.
/// - All other variants (`Mcp`, `WebSearch`, `FileSearch`, `CodeInterpreter`) return
/// `None` and emit a `tracing::debug!` — their full handlers have not landed yet.
///
/// This is the entry point called by `RequestPayload::to_upstream_request()` so that
/// vLLM always receives a `Vec<FunctionTool>`, never a raw `ResponsesTool` enum.
#[must_use]
pub fn to_function_tool(&self) -> Option<FunctionTool> {
match self {
// name is NonEmptyToolName — empty names are rejected by serde at
// deserialization time, so no runtime check is needed here.
ResponsesTool::Function(p) => Some(FunctionTool::from(p)),
ResponsesTool::Mcp(p) => {
tracing::debug!(
server_label = %p.server_label,
"MCP tool skipped in normalize — handler not yet registered"
);
None
}
ResponsesTool::WebSearch(_) => {
tracing::debug!("web_search tool skipped in normalize — handler not yet registered");
None
}
ResponsesTool::FileSearch(_) => {
tracing::debug!("file_search tool skipped in normalize — handler not yet registered");
None
}
ResponsesTool::CodeInterpreter(_) => {
tracing::debug!("code_interpreter tool skipped in normalize — handler not yet registered");
None
}
}
}
}

impl From<ToolOutput> for FunctionToolResultMessage {
fn from(o: ToolOutput) -> Self {
Self {
call_id: o.call_id,
output: o.output,
}
}
}
Loading