use std::fmt;
use std::sync::Arc;
use juncture_core::edge::{END, PathMap, RouteResult, Router};
use juncture_core::error::JunctureError;
use juncture_core::graph::{CompiledGraph, StateGraph, TopologyError};
use juncture_core::node::{IntoNode, Node};
use juncture_core::state::messages::Message;
use juncture_core::store::Store;
use juncture_core::{Command, RunnableConfig};
use crate::llm::{CallOptions, ChatModel, ToolDefinition as LlmToolDefinition};
use crate::prebuilt::messages_state::{MessagesState, MessagesStateUpdate};
use crate::tools::{Tool, ToolDefinition, ToolNode};
type DynamicPromptFn = Arc<dyn Fn(&[Message]) -> String + Send + Sync>;
type PreModelHook = Arc<dyn Fn(&MessagesState) -> MessagesState + Send + Sync>;
type PostModelHook = Arc<dyn Fn(&MessagesState, &Message) -> Message + Send + Sync>;
type ModelSelector = Arc<dyn Fn(&MessagesState) -> CallOptions + Send + Sync>;
pub(super) fn convert_tool_defs(defs: &[ToolDefinition]) -> Vec<LlmToolDefinition> {
defs.iter()
.map(|d| LlmToolDefinition {
name: d.name.clone(),
description: d.description.clone(),
parameters: d.parameters.clone(),
})
.collect()
}
pub fn create_agent<M: ChatModel>(
model: M,
tools: Vec<Box<dyn Tool>>,
) -> Result<CompiledGraph<MessagesState>, TopologyError> {
create_agent_with_config(model, tools, ReactAgentConfig::default())
}
pub fn create_react_agent<M: ChatModel>(
model: M,
tools: Vec<Box<dyn Tool>>,
) -> Result<CompiledGraph<MessagesState>, TopologyError> {
create_agent(model, tools)
}
#[allow(
clippy::needless_pass_by_value,
reason = "model ownership is transferred into the graph"
)]
pub fn create_agent_with_config<M: ChatModel>(
model: M,
tools: Vec<Box<dyn Tool>>,
config: ReactAgentConfig,
) -> Result<CompiledGraph<MessagesState>, TopologyError> {
let tool_defs: Vec<ToolDefinition> = tools.iter().map(|t| t.definition()).collect();
let llm_tool_defs = convert_tool_defs(&tool_defs);
let model_with_tools = model.bind_tools(llm_tool_defs);
let prompt = config.system_message.map(PromptSource::Static);
let mut agent_node = AgentNode::new_with_prompt_option(model_with_tools, prompt);
if let Some(hook) = config.pre_model_hook {
agent_node = agent_node.with_pre_model_hook(hook);
}
if let Some(hook) = config.post_model_hook {
agent_node = agent_node.with_post_model_hook(hook);
}
if let Some(selector) = config.model_selector {
agent_node = agent_node.with_model_selector(selector);
}
let tool_node = Arc::new(ToolNode::new(tools));
let tool_adapter = ToolNodeAdapter::new(tool_node, config.store);
let mut graph = StateGraph::<MessagesState>::new();
graph.add_node_simple("agent", agent_node)?;
graph.add_node_simple("tools", tool_adapter)?;
graph.set_entry_point("agent");
let path_map = PathMap::from(&[("tools", "tools"), (END, END)][..]);
graph.add_conditional_edges("agent", Arc::new(AgentRouter), path_map);
graph.add_edge("tools", "agent");
graph.compile()
}
#[allow(
clippy::needless_pass_by_value,
reason = "model ownership is transferred into the graph"
)]
pub fn create_react_agent_with_config<M: ChatModel>(
model: M,
tools: Vec<Box<dyn Tool>>,
config: ReactAgentConfig,
) -> Result<CompiledGraph<MessagesState>, TopologyError> {
create_agent_with_config(model, tools, config)
}
#[derive(Clone)]
pub enum PromptSource {
Static(String),
Dynamic(DynamicPromptFn),
}
impl fmt::Debug for PromptSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Static(s) => f.debug_tuple("Static").field(s).finish(),
Self::Dynamic(_) => f.debug_tuple("Dynamic").field(&"<fn>").finish(),
}
}
}
#[derive(Clone, Default)]
pub struct ReactAgentConfig {
pub system_message: Option<String>,
pub max_iterations: Option<usize>,
pub interrupt_before_tools: bool,
pub pre_model_hook: Option<PreModelHook>,
pub post_model_hook: Option<PostModelHook>,
pub model_selector: Option<ModelSelector>,
pub store: Option<Arc<dyn Store>>,
}
impl fmt::Debug for ReactAgentConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ReactAgentConfig")
.field("system_message", &self.system_message)
.field("max_iterations", &self.max_iterations)
.field("interrupt_before_tools", &self.interrupt_before_tools)
.field(
"pre_model_hook",
&self.pre_model_hook.as_ref().map(|_| "..."),
)
.field(
"post_model_hook",
&self.post_model_hook.as_ref().map(|_| "..."),
)
.field(
"model_selector",
&self.model_selector.as_ref().map(|_| "..."),
)
.field("store", &self.store.as_ref().map(|_| "..."))
.finish()
}
}
pub struct AgentNode<M: ChatModel> {
model: M,
prompt: Option<PromptSource>,
pre_model_hook: Option<PreModelHook>,
post_model_hook: Option<PostModelHook>,
model_selector: Option<ModelSelector>,
}
impl<M: ChatModel> AgentNode<M> {
#[must_use]
pub fn new(model: M) -> Self {
Self {
model,
prompt: None,
pre_model_hook: None,
post_model_hook: None,
model_selector: None,
}
}
#[must_use]
pub fn with_prompt(model: M, prompt: PromptSource) -> Self {
Self {
model,
prompt: Some(prompt),
pre_model_hook: None,
post_model_hook: None,
model_selector: None,
}
}
#[must_use]
fn new_with_prompt_option(model: M, prompt: Option<PromptSource>) -> Self {
Self {
model,
prompt,
pre_model_hook: None,
post_model_hook: None,
model_selector: None,
}
}
#[must_use]
pub fn with_pre_model_hook(mut self, hook: PreModelHook) -> Self {
self.pre_model_hook = Some(hook);
self
}
#[must_use]
pub fn with_post_model_hook(mut self, hook: PostModelHook) -> Self {
self.post_model_hook = Some(hook);
self
}
#[must_use]
pub fn with_model_selector(mut self, selector: ModelSelector) -> Self {
self.model_selector = Some(selector);
self
}
fn build_messages(&self, state: &MessagesState) -> Vec<Message> {
match &self.prompt {
Some(PromptSource::Static(text)) => {
let mut msgs = vec![Message::system(text)];
msgs.extend_from_slice(&state.messages);
msgs
}
Some(PromptSource::Dynamic(func)) => {
let text = func(&state.messages);
let mut msgs = vec![Message::system(&text)];
msgs.extend_from_slice(&state.messages);
msgs
}
None => state.messages.clone(),
}
}
}
impl<M: ChatModel> fmt::Debug for AgentNode<M> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AgentNode")
.field("model", &self.model.model_name())
.field("prompt", &self.prompt)
.field(
"pre_model_hook",
&self.pre_model_hook.as_ref().map(|_| "..."),
)
.field(
"post_model_hook",
&self.post_model_hook.as_ref().map(|_| "..."),
)
.field(
"model_selector",
&self.model_selector.as_ref().map(|_| "..."),
)
.finish()
}
}
impl<M: ChatModel> Node<MessagesState> for AgentNode<M> {
fn call(
&self,
state: &MessagesState,
config: &RunnableConfig,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<Command<MessagesState>, JunctureError>>
+ Send
+ '_,
>,
> {
let budget_tracker = config.budget_tracker().cloned();
let state = self
.pre_model_hook
.as_ref()
.map_or_else(|| state.clone(), |hook| hook(state));
let messages = self.build_messages(&state);
let options = self.model_selector.as_ref().map(|sel| sel(&state));
Box::pin(async move {
let response = juncture_core::wasm_send::force_send(
self.model.invoke(&messages, options.as_ref()),
)
.await
.map_err(|e| JunctureError::execution(e.to_string()))?;
let response = match &self.post_model_hook {
Some(hook) => hook(&state, &response),
None => response,
};
if let Some(usage) = &response.usage
&& let Some(ref tracker) = budget_tracker
{
tracker.report_model_call(usage.input_tokens, usage.output_tokens);
}
let update = MessagesStateUpdate {
messages: Some(vec![response]),
};
Ok(Command::update(update))
})
}
fn name(&self) -> &'static str {
"agent"
}
}
impl<M: ChatModel> IntoNode<MessagesState> for AgentNode<M> {
fn into_node(self, name: &str) -> Arc<dyn Node<MessagesState>> {
Arc::new(NamedNodeWrapper {
inner: self,
name: name.to_string(),
})
}
}
struct NamedNodeWrapper<N> {
inner: N,
name: String,
}
impl<N: Node<MessagesState>> fmt::Debug for NamedNodeWrapper<N> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NamedNodeWrapper")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
impl<N: Node<MessagesState>> Node<MessagesState> for NamedNodeWrapper<N> {
fn call(
&self,
state: &MessagesState,
config: &RunnableConfig,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<Command<MessagesState>, JunctureError>>
+ Send
+ '_,
>,
> {
self.inner.call(state, config)
}
fn name(&self) -> &str {
&self.name
}
}
struct AgentRouter;
impl Router<MessagesState> for AgentRouter {
fn route(
&self,
state: &MessagesState,
) -> std::pin::Pin<
Box<dyn std::future::Future<Output = Result<RouteResult, JunctureError>> + Send + '_>,
> {
let target = state
.messages
.last()
.map_or(END, |m| if m.has_tool_calls() { "tools" } else { END });
let result = RouteResult::One(target.to_string());
Box::pin(async move { Ok(result) })
}
}
struct ToolNodeAdapter {
tool_node: Arc<ToolNode<MessagesState>>,
store: Option<Arc<dyn Store>>,
}
impl ToolNodeAdapter {
#[must_use]
fn new(tool_node: Arc<ToolNode<MessagesState>>, store: Option<Arc<dyn Store>>) -> Self {
Self { tool_node, store }
}
}
impl fmt::Debug for ToolNodeAdapter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ToolNodeAdapter")
.field("tool_node", &self.tool_node)
.field("store", &self.store.as_ref().map(|_| "..."))
.finish()
}
}
impl Node<MessagesState> for ToolNodeAdapter {
fn call(
&self,
state: &MessagesState,
_config: &RunnableConfig,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<Command<MessagesState>, JunctureError>>
+ Send
+ '_,
>,
> {
let messages = state.messages.clone();
let tool_node = Arc::clone(&self.tool_node);
let state_owned = state.clone();
Box::pin(async move {
let results = tool_node
.execute_with_state(&messages, Some(&state_owned))
.await
.map_err(|e| JunctureError::execution(e.to_string()))?;
let update = MessagesStateUpdate {
messages: Some(results),
};
Ok(Command::update(update))
})
}
fn name(&self) -> &'static str {
"tools"
}
}
impl IntoNode<MessagesState> for ToolNodeAdapter {
fn into_node(self, name: &str) -> Arc<dyn Node<MessagesState>> {
Arc::new(NamedNodeWrapper {
inner: self,
name: name.to_string(),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::llm::MockChatModel;
use crate::tools::ToolError;
use async_trait::async_trait;
use juncture_core::State as _;
use juncture_core::state::messages::Content;
use juncture_core::state::messages::ToolCall;
use serde_json::json;
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &'static str {
"echo"
}
fn description(&self) -> &'static str {
"Echoes back the input message"
}
fn schema(&self) -> serde_json::Value {
json!({
"type": "object",
"properties": {
"message": {"type": "string"}
},
"required": ["message"]
})
}
async fn invoke(&self, input: serde_json::Value) -> Result<String, ToolError> {
input["message"]
.as_str()
.map(std::string::ToString::to_string)
.ok_or_else(|| ToolError::invalid_input("Missing 'message' field".to_string()))
}
}
#[test]
fn test_react_agent_config_default() {
let config = ReactAgentConfig::default();
assert!(config.system_message.is_none());
assert!(config.max_iterations.is_none());
assert!(!config.interrupt_before_tools);
}
#[test]
fn test_prompt_source_static_debug() {
let prompt = PromptSource::Static("You are helpful.".to_string());
let debug = format!("{prompt:?}");
assert!(debug.contains("Static"));
assert!(debug.contains("You are helpful."));
}
#[test]
fn test_prompt_source_dynamic_debug() {
let prompt = PromptSource::Dynamic(Arc::new(|_msgs: &[Message]| "dynamic".to_string()));
let debug = format!("{prompt:?}");
assert!(debug.contains("Dynamic"));
assert!(debug.contains("<fn>"));
}
#[test]
fn test_agent_node_debug() {
let model = MockChatModel::new("gpt-4");
let node = AgentNode::new(model);
let debug = format!("{node:?}");
assert!(debug.contains("AgentNode"));
assert!(debug.contains("gpt-4"));
}
#[test]
fn test_agent_node_with_prompt_debug() {
let model = MockChatModel::new("gpt-4");
let prompt = PromptSource::Static("You are a calculator.".to_string());
let node = AgentNode::with_prompt(model, prompt);
let debug = format!("{node:?}");
assert!(debug.contains("AgentNode"));
assert!(debug.contains("prompt"));
}
#[test]
fn test_messages_state_update_default() {
let update = MessagesStateUpdate::default();
assert!(update.messages.is_none());
}
#[test]
fn test_messages_state_apply_append() {
let mut state = MessagesState {
messages: vec![Message::human("Hello")],
};
let update = MessagesStateUpdate {
messages: Some(vec![Message::ai("Hi there!")]),
};
let changed = state.apply(update);
assert!(!changed.is_empty());
assert_eq!(state.messages.len(), 2);
}
#[test]
fn test_messages_state_apply_no_change() {
let mut state = MessagesState {
messages: vec![Message::human("Hello")],
};
let update = MessagesStateUpdate { messages: None };
let changed = state.apply(update);
assert!(changed.is_empty());
assert_eq!(state.messages.len(), 1);
}
#[tokio::test]
async fn test_agent_node_call_without_prompt() {
let model = MockChatModel::new("gpt-4").with_response("Hello back!");
let node = AgentNode::new(model);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
let cmd = result.unwrap();
assert!(cmd.update.is_some());
}
#[tokio::test]
async fn test_agent_node_call_with_static_prompt() {
let model = MockChatModel::new("gpt-4").with_response("Calculated!");
let prompt = PromptSource::Static("You are a calculator.".to_string());
let node = AgentNode::with_prompt(model, prompt);
let state = MessagesState {
messages: vec![Message::human("What is 2+2?")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap();
}
#[tokio::test]
async fn test_agent_node_call_with_dynamic_prompt() {
let model = MockChatModel::new("gpt-4").with_response("Response");
let prompt = PromptSource::Dynamic(Arc::new(|msgs: &[Message]| {
format!("Context: {} messages", msgs.len())
}));
let node = AgentNode::with_prompt(model, prompt);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap();
}
#[tokio::test]
async fn test_agent_node_call_model_error() {
let model = MockChatModel::new("gpt-4").with_error();
let node = AgentNode::new(model);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap_err();
}
#[tokio::test]
async fn test_tool_node_adapter() {
let tool_node = Arc::new(ToolNode::new(vec![Box::new(EchoTool)]));
let adapter = ToolNodeAdapter::new(tool_node, None);
let state = MessagesState {
messages: vec![Message::ai_with_tool_calls(
"Echo this",
vec![ToolCall {
id: "call_1".to_string(),
name: "echo".to_string(),
arguments: json!({"message": "hello"}),
}],
)],
};
let result = adapter.call(&state, &RunnableConfig::default()).await;
assert!(result.is_ok());
let cmd = result.unwrap();
assert!(cmd.update.is_some());
let update = cmd.update.unwrap();
assert!(update.messages.is_some());
let tool_messages = update.messages.unwrap();
assert_eq!(tool_messages.len(), 1);
}
#[tokio::test]
async fn test_tool_node_adapter_no_tool_calls() {
let tool_node = Arc::new(ToolNode::new(vec![Box::new(EchoTool)]));
let adapter = ToolNodeAdapter::new(tool_node, None);
let state = MessagesState {
messages: vec![Message::ai("No tools here")],
};
let result = adapter.call(&state, &RunnableConfig::default()).await;
result.unwrap_err();
}
#[test]
fn test_create_react_agent_basic() {
let model = MockChatModel::new("gpt-4").with_response("Hello!");
let tools: Vec<Box<dyn Tool>> = vec![];
let result = create_react_agent(model, tools);
result.unwrap();
}
#[test]
fn test_create_react_agent_with_tools() {
let model = MockChatModel::new("gpt-4").with_response("Let me search for that.");
let tools: Vec<Box<dyn Tool>> = vec![Box::new(EchoTool)];
let result = create_react_agent(model, tools);
result.unwrap();
}
#[test]
fn test_create_react_agent_with_config() {
let model = MockChatModel::new("gpt-4").with_response("Done!");
let tools: Vec<Box<dyn Tool>> = vec![];
let config = ReactAgentConfig {
system_message: Some("You are a helpful assistant.".to_string()),
max_iterations: Some(10),
interrupt_before_tools: false,
..Default::default()
};
let result = create_react_agent_with_config(model, tools, config);
result.unwrap();
}
#[test]
fn test_react_agent_config_new_fields_default() {
let config = ReactAgentConfig::default();
assert!(config.system_message.is_none());
assert!(config.max_iterations.is_none());
assert!(!config.interrupt_before_tools);
assert!(config.pre_model_hook.is_none());
assert!(config.post_model_hook.is_none());
assert!(config.model_selector.is_none());
assert!(config.store.is_none());
}
#[test]
fn test_react_agent_config_debug_with_new_fields() {
let config = ReactAgentConfig::default();
let debug = format!("{config:?}");
assert!(debug.contains("ReactAgentConfig"));
assert!(debug.contains("pre_model_hook"));
assert!(debug.contains("post_model_hook"));
assert!(debug.contains("model_selector"));
assert!(debug.contains("store"));
}
#[test]
#[allow(
clippy::redundant_clone,
reason = "intentional clone to verify Clone impl preserves all fields including Arc-wrapped function types"
)]
fn test_react_agent_config_clone_with_all_fields() {
use juncture_core::store::MemoryStore;
let store = Arc::new(MemoryStore::new()) as Arc<dyn Store>;
let config = ReactAgentConfig {
system_message: Some("Hello".to_string()),
max_iterations: Some(5),
interrupt_before_tools: true,
pre_model_hook: Some(Arc::new(|s: &MessagesState| s.clone())),
post_model_hook: Some(Arc::new(|_s: &MessagesState, r: &Message| r.clone())),
model_selector: Some(Arc::new(|_s: &MessagesState| CallOptions {
model_override: Some("gpt-4-turbo".to_string()),
..Default::default()
})),
store: Some(store),
};
assert_eq!(config.system_message, Some("Hello".to_string()));
assert_eq!(config.max_iterations, Some(5));
assert!(config.interrupt_before_tools);
assert!(config.pre_model_hook.is_some());
assert!(config.post_model_hook.is_some());
assert!(config.model_selector.is_some());
assert!(config.store.is_some());
let cloned = config.clone();
assert!(cloned.pre_model_hook.is_some());
assert!(cloned.post_model_hook.is_some());
assert!(cloned.model_selector.is_some());
assert!(cloned.store.is_some());
}
#[tokio::test]
async fn test_agent_node_call_with_model_selector() {
let model = MockChatModel::new("gpt-4").with_response("Response");
let selector: ModelSelector = Arc::new(|_state: &MessagesState| CallOptions {
model_override: Some("gpt-4-turbo".to_string()),
temperature: Some(0.5),
..Default::default()
});
let node = AgentNode::new(model).with_model_selector(selector);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap();
}
#[tokio::test]
async fn test_agent_node_call_with_pre_model_hook() {
let model = MockChatModel::new("gpt-4").with_response("Response");
let hook: PreModelHook = Arc::new(|state: &MessagesState| {
let mut new_state = state.clone();
new_state
.messages
.push(Message::system("Hook added context"));
new_state
});
let node = AgentNode::new(model).with_pre_model_hook(hook);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap();
}
#[tokio::test]
async fn test_agent_node_call_with_post_model_hook() {
let model = MockChatModel::new("gpt-4").with_response("Initial response");
let hook: PostModelHook = Arc::new(|_state: &MessagesState, response: &Message| {
let text = match &response.content {
Content::Text(t) => format!("[Post-processed] {t}"),
Content::MultiPart(_) => "[Post-processed multi-part]".to_string(),
};
Message::ai(&text)
});
let node = AgentNode::new(model).with_post_model_hook(hook);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let result = node.call(&state, &RunnableConfig::default()).await;
result.unwrap();
}
#[test]
fn test_tool_node_adapter_with_store() {
use juncture_core::store::MemoryStore;
let store = Arc::new(MemoryStore::new()) as Arc<dyn Store>;
let tool_node = Arc::new(ToolNode::new(vec![Box::new(EchoTool)]));
let adapter = ToolNodeAdapter::new(tool_node, Some(store));
let debug = format!("{adapter:?}");
assert!(debug.contains("ToolNodeAdapter"));
assert!(debug.contains("store"));
assert!(debug.contains("..."));
}
#[test]
fn test_react_agent_config_builder_default_store_missing() {
let config = ReactAgentConfig::default();
assert!(config.store.is_none());
}
#[test]
fn test_agent_router_with_tool_calls() {
let state = MessagesState {
messages: vec![Message::ai_with_tool_calls(
"Let me look that up",
vec![ToolCall {
id: "call_1".to_string(),
name: "search".to_string(),
arguments: json!({}),
}],
)],
};
let router = AgentRouter;
let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
let result = rt.block_on(router.route(&state)).unwrap();
assert_eq!(result, RouteResult::One("tools".to_string()));
}
#[test]
fn test_agent_router_without_tool_calls() {
let state = MessagesState {
messages: vec![Message::ai("Here is the answer.")],
};
let router = AgentRouter;
let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
let result = rt.block_on(router.route(&state)).unwrap();
assert_eq!(result, RouteResult::One(END.to_string()));
}
#[test]
fn test_agent_router_empty_messages() {
let state = MessagesState { messages: vec![] };
let router = AgentRouter;
let rt = tokio::runtime::Runtime::new().expect("Failed to create runtime");
let result = rt.block_on(router.route(&state)).unwrap();
assert_eq!(result, RouteResult::One(END.to_string()));
}
#[test]
fn test_build_messages_without_prompt() {
let model = MockChatModel::new("gpt-4");
let node = AgentNode::new(model);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let msgs = node.build_messages(&state);
assert_eq!(msgs.len(), 1);
}
#[test]
fn test_build_messages_with_static_prompt() {
let model = MockChatModel::new("gpt-4");
let prompt = PromptSource::Static("You are helpful.".to_string());
let node = AgentNode::with_prompt(model, prompt);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let msgs = node.build_messages(&state);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, juncture_core::state::messages::Role::System);
}
#[test]
fn test_build_messages_with_dynamic_prompt() {
let model = MockChatModel::new("gpt-4");
let prompt =
PromptSource::Dynamic(Arc::new(|_msgs: &[Message]| "Dynamic prompt".to_string()));
let node = AgentNode::with_prompt(model, prompt);
let state = MessagesState {
messages: vec![Message::human("Hello")],
};
let msgs = node.build_messages(&state);
assert_eq!(msgs.len(), 2);
assert_eq!(msgs[0].role, juncture_core::state::messages::Role::System);
}
#[test]
fn test_tool_node_adapter_debug() {
let tool_node = Arc::new(ToolNode::new(vec![Box::new(EchoTool)]));
let adapter = ToolNodeAdapter::new(tool_node, None);
let debug = format!("{adapter:?}");
assert!(debug.contains("ToolNodeAdapter"));
}
#[test]
fn test_convert_tool_defs() {
let defs = vec![ToolDefinition::new(
"search",
"Search the web",
json!({"type": "object"}),
)];
let llm_defs = convert_tool_defs(&defs);
assert_eq!(llm_defs.len(), 1);
assert_eq!(llm_defs[0].name, "search");
assert_eq!(llm_defs[0].description, "Search the web");
}
}