1use crate::interceptor::ToolCallInterceptor;
13use crate::registry::ToolRegistry;
14use pe_core::error::PeError;
15use pe_core::message::{Message, ToolCall};
16use pe_core::node::{NodeContext, NodeFn, NodeFuture, NodeResult};
17use pe_core::state::{CoreState, CoreStateUpdate};
18use std::marker::PhantomData;
19use std::sync::Arc;
20
21pub struct ToolNode<S: CoreState> {
46 registry: Arc<ToolRegistry>,
47 handle_tool_errors: bool,
48 interceptor: Option<Arc<dyn ToolCallInterceptor>>,
49 _phantom: PhantomData<S>,
50}
51
52impl<S: CoreState> ToolNode<S>
53where
54 S::Update: CoreStateUpdate,
55{
56 pub fn new(registry: Arc<ToolRegistry>) -> Self {
59 Self {
60 registry,
61 handle_tool_errors: true,
62 interceptor: None,
63 _phantom: PhantomData,
64 }
65 }
66
67 #[must_use = "builder methods return the modified builder"]
69 pub fn with_error_handling(mut self, handle: bool) -> Self {
70 self.handle_tool_errors = handle;
71 self
72 }
73
74 #[must_use = "builder methods return the modified builder"]
76 pub fn with_interceptor(mut self, interceptor: impl ToolCallInterceptor + 'static) -> Self {
77 self.interceptor = Some(Arc::new(interceptor));
78 self
79 }
80}
81
82impl<S: CoreState> NodeFn<S> for ToolNode<S>
83where
84 S::Update: CoreStateUpdate,
85{
86 fn call(&self, state: &S, ctx: &NodeContext) -> NodeFuture<S::Update> {
87 let registry = self.registry.clone();
88 let handle_errors = self.handle_tool_errors;
89 let interceptor = self.interceptor.clone();
90 let tool_observer = ctx.tool_observer.clone();
91
92 let tool_calls = extract_tool_calls(state.messages());
94 if tool_calls.is_empty() {
95 return Box::pin(async move { NodeResult::Update(S::Update::default()) });
97 }
98
99 Box::pin(async move {
100 let tool_calls = match &interceptor {
102 Some(int) => tool_calls.into_iter().map(|tc| int.intercept(tc)).collect(),
103 None => tool_calls,
104 };
105
106 let tasks: Vec<_> = tool_calls
108 .into_iter()
109 .map(|tc| {
110 let reg = registry.clone();
111 let obs = tool_observer.clone();
112 tokio::spawn(async move {
113 execute_single_tool_observed(®, tc, handle_errors, obs.as_deref()).await
114 })
115 })
116 .collect();
117
118 let results = futures::future::join_all(tasks).await;
119
120 let mut messages = Vec::new();
122 for join_result in results {
123 match join_result {
124 Ok(ToolExecResult::Message(msg)) => messages.push(msg),
125 Ok(ToolExecResult::PropagateError(err)) => {
126 return NodeResult::Error(err);
127 }
128 Err(join_err) => {
129 return NodeResult::Error(PeError::Internal {
131 details: format!("Tool task panicked: {join_err}"),
132 });
133 }
134 }
135 }
136
137 let update = <S::Update as CoreStateUpdate>::from_messages(messages);
139 NodeResult::Update(update)
140 })
141 }
142
143 fn name(&self) -> &str {
144 "tool_node"
145 }
146}
147
148enum ToolExecResult {
150 Message(Message),
152 PropagateError(PeError),
154}
155
156async fn execute_single_tool_observed(
161 registry: &ToolRegistry,
162 tc: ToolCall,
163 handle_errors: bool,
164 observer: Option<&dyn pe_core::node::ToolObserver>,
165) -> ToolExecResult {
166 let input_summary = summarize_tool_input(&tc);
168
169 if let Some(obs) = observer {
170 obs.on_tool_start(&tc.name, &input_summary).await;
171 }
172
173 let start = std::time::Instant::now();
174 let result = match registry.get(&tc.name) {
175 None => {
176 tracing::warn!("Tool '{}' not found in registry", tc.name);
177 if let Some(obs) = observer {
178 obs.on_tool_error(&tc.name, "Tool not found in registry")
179 .await;
180 }
181 return ToolExecResult::Message(Message::tool(
182 format!("Tool '{}' not found in registry.", tc.name),
183 tc.id,
184 ));
185 }
186 Some(tool) => tool.execute_structured(tc.args.clone()).await,
187 };
188 let elapsed = start.elapsed();
189
190 match result {
191 Ok(tool_result) => {
192 if let Some(obs) = observer {
193 obs.on_tool_complete(&tc.name, elapsed).await;
194 }
195 let metadata = if tool_result.metadata.is_empty() {
196 None
197 } else {
198 Some(tool_result.metadata)
199 };
200 ToolExecResult::Message(Message::tool_with_metadata(
201 tool_result.output.to_string(),
202 tc.id,
203 metadata,
204 Some(elapsed.as_millis() as u64),
205 ))
206 }
207 Err(e) if handle_errors => {
208 tracing::warn!("Tool '{}' failed (handled): {}", tc.name, e);
209 if let Some(obs) = observer {
210 obs.on_tool_error(&tc.name, &e.to_string()).await;
211 }
212 ToolExecResult::Message(Message::tool_with_metadata(
213 format!("Tool error: {e}"),
214 tc.id,
215 None,
216 Some(elapsed.as_millis() as u64),
217 ))
218 }
219 Err(e) => {
220 tracing::error!("Tool '{}' failed (propagating): {}", tc.name, e);
221 if let Some(obs) = observer {
222 obs.on_tool_error(&tc.name, &e.to_string()).await;
223 }
224 ToolExecResult::PropagateError(PeError::ToolExecution {
225 tool: tc.name,
226 reason: e.to_string(),
227 })
228 }
229 }
230}
231
232fn summarize_tool_input(tc: &ToolCall) -> String {
234 let s = tc.args.to_string();
235 if s.len() <= 100 {
236 s
237 } else {
238 format!("{}...", &s[..97])
239 }
240}
241
242fn extract_tool_calls(messages: &[Message]) -> Vec<ToolCall> {
244 messages
245 .iter()
246 .rev()
247 .find_map(|m| {
248 if let Message::Ai(ai) = m {
249 if !ai.tool_calls.is_empty() {
250 return Some(ai.tool_calls.clone());
251 }
252 }
253 None
254 })
255 .unwrap_or_default()
256}
257
258impl<S: CoreState> std::fmt::Debug for ToolNode<S>
259where
260 S::Update: CoreStateUpdate,
261{
262 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
263 f.debug_struct("ToolNode")
264 .field("handle_tool_errors", &self.handle_tool_errors)
265 .field("has_interceptor", &self.interceptor.is_some())
266 .finish()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273 use crate::interceptor::ToolCallInterceptor;
274 use crate::tool::FunctionTool;
275 use pe_core::message::{AiMessage, MessageContent};
276 use pe_core::state::{CoreStateUpdate, ExecutionContext, StateUpdate};
277 use serde::{Deserialize, Serialize};
278 use std::collections::HashMap;
279 use std::time::{Duration, Instant};
280
281 #[derive(Debug, Clone, Serialize, Deserialize)]
284 struct ToolTestState {
285 messages: Vec<Message>,
286 iterations: u32,
287 thread_id: String,
288 context: ExecutionContext,
289 }
290
291 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
292 struct ToolTestUpdate {
293 messages: Option<Vec<Message>>,
294 }
295
296 impl StateUpdate for ToolTestUpdate {}
297
298 impl CoreStateUpdate for ToolTestUpdate {
299 fn from_messages(msgs: Vec<Message>) -> Self {
300 Self {
301 messages: Some(msgs),
302 }
303 }
304 }
305
306 impl pe_core::state::State for ToolTestState {
307 type Update = ToolTestUpdate;
308 fn apply(&mut self, update: Self::Update) {
309 if let Some(msgs) = update.messages {
310 self.messages.extend(msgs);
311 }
312 }
313 }
314
315 impl CoreState for ToolTestState {
316 fn messages(&self) -> &[Message] {
317 &self.messages
318 }
319 fn messages_mut(&mut self) -> &mut Vec<Message> {
320 &mut self.messages
321 }
322 fn iterations(&self) -> u32 {
323 self.iterations
324 }
325 fn set_iterations(&mut self, n: u32) {
326 self.iterations = n;
327 }
328 fn thread_id(&self) -> &str {
329 &self.thread_id
330 }
331 fn context(&self) -> &ExecutionContext {
332 &self.context
333 }
334 fn context_mut(&mut self) -> &mut ExecutionContext {
335 &mut self.context
336 }
337 }
338
339 fn make_ctx() -> NodeContext {
340 NodeContext {
341 step: 1,
342 recursion_limit: 25,
343 node_name: "tool_node".into(),
344 activation: pe_core::node::ActivationReason::EntryPoint,
345 metadata: HashMap::new(),
346 phase_store: pe_core::phase_store::PhaseStateStore::new(),
347 stream_sender: None,
348 tool_observer: None,
349 lobe_runtime_service_factory: None,
350 }
351 }
352
353 fn make_state_with_tool_calls(calls: Vec<ToolCall>) -> ToolTestState {
354 ToolTestState {
355 messages: vec![Message::Ai(AiMessage {
356 content: MessageContent::Text("Calling tools...".into()),
357 tool_calls: calls,
358 invalid_tool_calls: vec![],
359 usage_metadata: None,
360 response_metadata: HashMap::new(),
361 id: None,
362 })],
363 iterations: 0,
364 thread_id: "t1".into(),
365 context: ExecutionContext::new("test"),
366 }
367 }
368
369 fn make_echo_registry() -> ToolRegistry {
370 let mut reg = ToolRegistry::new();
371 reg.register(FunctionTool::new(
372 "echo",
373 "Echoes input",
374 serde_json::json!({"type": "object"}),
375 |input| Box::pin(async move { Ok(input) }),
376 ))
377 .unwrap();
378 reg
379 }
380
381 #[tokio::test]
384 async fn single_tool_call_produces_tool_message() {
385 let reg = make_echo_registry();
386 let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
387
388 let state = make_state_with_tool_calls(vec![ToolCall {
389 id: "tc_1".into(),
390 name: "echo".into(),
391 args: serde_json::json!({"msg": "hello"}),
392 }]);
393
394 let result = node.call(&state, &make_ctx()).await;
395
396 match result {
397 NodeResult::Update(update) => {
398 let msgs = update.messages.expect("should have messages");
399 assert_eq!(msgs.len(), 1);
400 if let Message::Tool(tm) = &msgs[0] {
401 assert_eq!(tm.tool_call_id, "tc_1");
402 assert!(tm.content.contains("hello"));
403 } else {
404 panic!("expected ToolMessage, got {:?}", msgs[0]);
405 }
406 }
407 other => panic!("expected Update, got {:?}", other),
408 }
409 }
410
411 #[tokio::test]
412 async fn parallel_tool_calls_all_execute() {
413 let mut reg = ToolRegistry::new();
414 for name in ["tool_a", "tool_b", "tool_c"] {
416 let n = name.to_string();
417 reg.register(FunctionTool::new(
418 name,
419 format!("Tool {name}"),
420 serde_json::json!({"type": "object"}),
421 move |_| {
422 let n = n.clone();
423 Box::pin(async move {
424 tokio::time::sleep(Duration::from_millis(50)).await;
425 Ok(serde_json::json!({"from": n}))
426 })
427 },
428 ))
429 .unwrap();
430 }
431
432 let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
433 let state = make_state_with_tool_calls(vec![
434 ToolCall {
435 id: "tc_a".into(),
436 name: "tool_a".into(),
437 args: serde_json::json!({}),
438 },
439 ToolCall {
440 id: "tc_b".into(),
441 name: "tool_b".into(),
442 args: serde_json::json!({}),
443 },
444 ToolCall {
445 id: "tc_c".into(),
446 name: "tool_c".into(),
447 args: serde_json::json!({}),
448 },
449 ]);
450
451 let start = Instant::now();
452 let result = node.call(&state, &make_ctx()).await;
453 let elapsed = start.elapsed();
454
455 assert!(
457 elapsed < Duration::from_millis(150),
458 "Expected parallel execution (<150ms), took {:?}",
459 elapsed
460 );
461
462 match result {
463 NodeResult::Update(update) => {
464 let msgs = update.messages.expect("should have messages");
465 assert_eq!(msgs.len(), 3);
466 for msg in &msgs {
468 assert!(matches!(msg, Message::Tool(_)));
469 }
470 }
471 other => panic!("expected Update, got {:?}", other),
472 }
473 }
474
475 #[tokio::test]
476 async fn tool_error_handled_becomes_tool_message() {
477 let mut reg = ToolRegistry::new();
478 reg.register(FunctionTool::new(
479 "fail",
480 "Always fails",
481 serde_json::json!({"type": "object"}),
482 |_| {
483 Box::pin(async {
484 Err(PeError::ToolExecution {
485 tool: "fail".into(),
486 reason: "boom".into(),
487 })
488 })
489 },
490 ))
491 .unwrap();
492
493 let node = ToolNode::<ToolTestState>::new(Arc::new(reg)).with_error_handling(true);
494
495 let state = make_state_with_tool_calls(vec![ToolCall {
496 id: "tc_fail".into(),
497 name: "fail".into(),
498 args: serde_json::json!({}),
499 }]);
500
501 let result = node.call(&state, &make_ctx()).await;
502 match result {
503 NodeResult::Update(update) => {
504 let msgs = update.messages.expect("should have messages");
505 assert_eq!(msgs.len(), 1);
506 if let Message::Tool(tm) = &msgs[0] {
507 assert!(tm.content.contains("Tool error"));
508 assert!(tm.content.contains("boom"));
509 assert_eq!(tm.tool_call_id, "tc_fail");
510 } else {
511 panic!("expected ToolMessage");
512 }
513 }
514 other => panic!("expected Update, got {:?}", other),
515 }
516 }
517
518 #[tokio::test]
519 async fn tool_error_propagated_when_handling_disabled() {
520 let mut reg = ToolRegistry::new();
521 reg.register(FunctionTool::new(
522 "fail",
523 "Always fails",
524 serde_json::json!({"type": "object"}),
525 |_| {
526 Box::pin(async {
527 Err(PeError::ToolExecution {
528 tool: "fail".into(),
529 reason: "critical failure".into(),
530 })
531 })
532 },
533 ))
534 .unwrap();
535
536 let node = ToolNode::<ToolTestState>::new(Arc::new(reg)).with_error_handling(false);
537
538 let state = make_state_with_tool_calls(vec![ToolCall {
539 id: "tc_fail".into(),
540 name: "fail".into(),
541 args: serde_json::json!({}),
542 }]);
543
544 let result = node.call(&state, &make_ctx()).await;
545 assert!(result.is_error(), "expected Error, got {:?}", result);
546 }
547
548 #[tokio::test]
549 async fn unknown_tool_always_produces_not_found_message() {
550 let reg = ToolRegistry::new(); let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
552
553 let state = make_state_with_tool_calls(vec![ToolCall {
554 id: "tc_missing".into(),
555 name: "nonexistent".into(),
556 args: serde_json::json!({}),
557 }]);
558
559 let result = node.call(&state, &make_ctx()).await;
560 match result {
561 NodeResult::Update(update) => {
562 let msgs = update.messages.expect("should have messages");
563 assert_eq!(msgs.len(), 1);
564 if let Message::Tool(tm) = &msgs[0] {
565 assert!(tm.content.contains("not found"));
566 assert_eq!(tm.tool_call_id, "tc_missing");
567 } else {
568 panic!("expected ToolMessage");
569 }
570 }
571 other => panic!("expected Update, got {:?}", other),
572 }
573 }
574
575 #[tokio::test]
576 async fn interceptor_modifies_args_before_execution() {
577 struct InjectKeyInterceptor;
578 impl ToolCallInterceptor for InjectKeyInterceptor {
579 fn intercept(&self, mut call: ToolCall) -> ToolCall {
580 if let Some(obj) = call.args.as_object_mut() {
581 obj.insert("injected".into(), serde_json::json!(true));
582 }
583 call
584 }
585 }
586
587 let reg = make_echo_registry();
588 let node =
589 ToolNode::<ToolTestState>::new(Arc::new(reg)).with_interceptor(InjectKeyInterceptor);
590
591 let state = make_state_with_tool_calls(vec![ToolCall {
592 id: "tc_int".into(),
593 name: "echo".into(),
594 args: serde_json::json!({"original": "data"}),
595 }]);
596
597 let result = node.call(&state, &make_ctx()).await;
598 match result {
599 NodeResult::Update(update) => {
600 let msgs = update.messages.expect("should have messages");
601 assert_eq!(msgs.len(), 1);
602 if let Message::Tool(tm) = &msgs[0] {
603 assert!(tm.content.contains("injected"));
605 assert!(tm.content.contains("original"));
606 } else {
607 panic!("expected ToolMessage");
608 }
609 }
610 other => panic!("expected Update, got {:?}", other),
611 }
612 }
613
614 #[tokio::test]
615 async fn tool_message_includes_duration_ms() {
616 let reg = make_echo_registry();
617 let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
618
619 let state = make_state_with_tool_calls(vec![ToolCall {
620 id: "tc_dur".into(),
621 name: "echo".into(),
622 args: serde_json::json!({"msg": "timing"}),
623 }]);
624
625 let result = node.call(&state, &make_ctx()).await;
626 match result {
627 NodeResult::Update(update) => {
628 let msgs = update.messages.expect("should have messages");
629 assert_eq!(msgs.len(), 1);
630 if let Message::Tool(tm) = &msgs[0] {
631 assert!(
632 tm.duration_ms.is_some(),
633 "ToolMessage should include duration_ms"
634 );
635 assert!(tm.duration_ms.unwrap() < 5000);
637 } else {
638 panic!("expected ToolMessage, got {:?}", msgs[0]);
639 }
640 }
641 other => panic!("expected Update, got {:?}", other),
642 }
643 }
644
645 #[tokio::test]
646 async fn tool_error_message_includes_duration_ms() {
647 let mut reg = ToolRegistry::new();
648 reg.register(FunctionTool::new(
649 "fail",
650 "Always fails",
651 serde_json::json!({"type": "object"}),
652 |_| {
653 Box::pin(async {
654 Err(PeError::ToolExecution {
655 tool: "fail".into(),
656 reason: "boom".into(),
657 })
658 })
659 },
660 ))
661 .unwrap();
662
663 let node = ToolNode::<ToolTestState>::new(Arc::new(reg)).with_error_handling(true);
664
665 let state = make_state_with_tool_calls(vec![ToolCall {
666 id: "tc_fail_dur".into(),
667 name: "fail".into(),
668 args: serde_json::json!({}),
669 }]);
670
671 let result = node.call(&state, &make_ctx()).await;
672 match result {
673 NodeResult::Update(update) => {
674 let msgs = update.messages.expect("should have messages");
675 assert_eq!(msgs.len(), 1);
676 if let Message::Tool(tm) = &msgs[0] {
677 assert!(
678 tm.duration_ms.is_some(),
679 "Error ToolMessage should include duration_ms"
680 );
681 } else {
682 panic!("expected ToolMessage");
683 }
684 }
685 other => panic!("expected Update, got {:?}", other),
686 }
687 }
688
689 #[tokio::test]
690 async fn structured_tool_metadata_passes_through() {
691 let mut reg = ToolRegistry::new();
693 reg.register(crate::tool::StructuredFunctionTool::new(
694 "search",
695 "Search things",
696 serde_json::json!({"type": "object"}),
697 |_input| {
698 Box::pin(async move {
699 Ok(
700 crate::tool::ToolResult::ok(serde_json::json!({"results": [1, 2, 3]}))
701 .with_metadata("result_count", serde_json::json!(3))
702 .with_metadata("source", serde_json::json!("index")),
703 )
704 })
705 },
706 ))
707 .unwrap();
708
709 let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
710 let state = make_state_with_tool_calls(vec![ToolCall {
711 id: "tc_meta".into(),
712 name: "search".into(),
713 args: serde_json::json!({}),
714 }]);
715
716 let result = node.call(&state, &make_ctx()).await;
717 match result {
718 NodeResult::Update(update) => {
719 let msgs = update.messages.expect("should have messages");
720 assert_eq!(msgs.len(), 1);
721 if let Message::Tool(tm) = &msgs[0] {
722 let meta = tm.metadata.as_ref().expect("should have metadata");
723 assert_eq!(meta["result_count"], serde_json::json!(3));
724 assert_eq!(meta["source"], serde_json::json!("index"));
725 } else {
726 panic!("expected ToolMessage");
727 }
728 }
729 other => panic!("expected Update, got {:?}", other),
730 }
731 }
732
733 #[tokio::test]
734 async fn no_tool_calls_returns_empty_update() {
735 let reg = make_echo_registry();
736 let node = ToolNode::<ToolTestState>::new(Arc::new(reg));
737
738 let state = ToolTestState {
740 messages: vec![Message::ai("No tools needed")],
741 iterations: 0,
742 thread_id: "t1".into(),
743 context: ExecutionContext::new("test"),
744 };
745
746 let result = node.call(&state, &make_ctx()).await;
747 match result {
748 NodeResult::Update(update) => {
749 assert!(
750 update.messages.is_none(),
751 "empty update should have no messages"
752 );
753 }
754 other => panic!("expected Update, got {:?}", other),
755 }
756 }
757}