Skip to main content

vv_agent/tools/
orchestrator.rs

1use std::collections::BTreeMap;
2use std::panic::{catch_unwind, AssertUnwindSafe};
3use std::sync::Arc;
4use std::time::Instant;
5
6use serde_json::json;
7
8use crate::tools::{
9    ApprovalPolicy, ApprovalRequirement, CanUseToolPredicate, ToolContext, ToolExecutor,
10    ToolMetadata, ToolPolicy, ToolRunContext,
11};
12use crate::types::{Metadata, ToolCall, ToolDirective, ToolExecutionResult, ToolResultStatus};
13
14pub type BeforeToolDispatch = Arc<
15    dyn Fn(&ToolCall, &mut ToolContext) -> Result<(), crate::tools::ToolError>
16        + Send
17        + Sync
18        + 'static,
19>;
20
21pub type ToolLifecycleCallback = Arc<dyn Fn(ToolLifecycleEvent) + Send + Sync + 'static>;
22
23#[derive(Debug, Clone)]
24pub enum ToolLifecycleEvent {
25    Planned {
26        call: ToolCall,
27        tool_metadata: Option<ToolMetadata>,
28    },
29    Started {
30        call: ToolCall,
31        tool_metadata: Option<ToolMetadata>,
32    },
33    Completed {
34        call: ToolCall,
35        result: ToolExecutionResult,
36        execution_started: bool,
37        duration_ms: Option<u64>,
38        tool_metadata: Option<ToolMetadata>,
39    },
40}
41
42pub(crate) struct DeferredToolExecution {
43    result: ToolExecutionResult,
44    lifecycle: Option<DeferredToolLifecycle>,
45}
46
47struct DeferredToolLifecycle {
48    call: ToolCall,
49    execution_started: bool,
50    duration_ms: Option<u64>,
51    tool_metadata: Option<ToolMetadata>,
52    callback: Option<ToolLifecycleCallback>,
53}
54
55impl DeferredToolExecution {
56    pub(crate) fn without_lifecycle(result: ToolExecutionResult) -> Self {
57        Self {
58            result,
59            lifecycle: None,
60        }
61    }
62
63    fn with_lifecycle(
64        call: ToolCall,
65        result: ToolExecutionResult,
66        execution_started: bool,
67        duration_ms: Option<u64>,
68        tool_metadata: Option<ToolMetadata>,
69        callback: Option<ToolLifecycleCallback>,
70    ) -> Self {
71        Self {
72            result,
73            lifecycle: Some(DeferredToolLifecycle {
74                call,
75                execution_started,
76                duration_ms,
77                tool_metadata,
78                callback,
79            }),
80        }
81    }
82
83    pub(crate) fn result(&self) -> &ToolExecutionResult {
84        &self.result
85    }
86
87    pub(crate) fn replace_result(&mut self, result: ToolExecutionResult) {
88        self.result = result;
89    }
90
91    pub(crate) fn execution_started(&self) -> bool {
92        self.lifecycle
93            .as_ref()
94            .is_some_and(|lifecycle| lifecycle.execution_started)
95    }
96
97    pub(crate) fn complete(self) -> ToolExecutionResult {
98        if let Some(lifecycle) = self.lifecycle {
99            emit_completed(
100                &lifecycle.call,
101                &self.result,
102                lifecycle.execution_started,
103                lifecycle.duration_ms,
104                lifecycle.tool_metadata,
105                lifecycle.callback.as_ref(),
106            );
107        }
108        self.result
109    }
110}
111
112#[derive(Clone, Default)]
113pub struct ToolRunOptions {
114    planned_tools: Option<Vec<String>>,
115    allowed_tools: Option<Vec<String>>,
116    disallowed_tools: Vec<String>,
117    can_use_tool: Option<CanUseToolPredicate>,
118    approval: ApprovalPolicy,
119    denied_side_effects: Vec<crate::tools::ToolSideEffect>,
120    denied_capability_tags: Vec<String>,
121    deny_terminal_tools: bool,
122    denied_cost_dimensions: Vec<String>,
123    idempotency_key: Option<String>,
124    before_dispatch: Option<BeforeToolDispatch>,
125    lifecycle_callback: Option<ToolLifecycleCallback>,
126}
127
128impl ToolRunOptions {
129    pub fn from_policy(policy: &ToolPolicy) -> Self {
130        Self {
131            planned_tools: None,
132            allowed_tools: policy.allowed_tools.clone(),
133            disallowed_tools: policy.disallowed_tools.clone(),
134            can_use_tool: policy.can_use_tool.clone(),
135            approval: policy.approval,
136            denied_side_effects: policy.denied_side_effects.clone(),
137            denied_capability_tags: policy.denied_capability_tags.clone(),
138            deny_terminal_tools: policy.deny_terminal_tools,
139            denied_cost_dimensions: policy.denied_cost_dimensions.clone(),
140            idempotency_key: None,
141            before_dispatch: None,
142            lifecycle_callback: None,
143        }
144    }
145
146    pub fn planned_names(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
147        self.planned_tools = Some(tools.into_iter().map(Into::into).collect());
148        self
149    }
150
151    pub fn allow_only(mut self, tools: impl IntoIterator<Item = impl Into<String>>) -> Self {
152        self.allowed_tools = Some(tools.into_iter().map(Into::into).collect());
153        self
154    }
155
156    pub fn disallow(mut self, tool: impl Into<String>) -> Self {
157        self.disallowed_tools.push(tool.into());
158        self
159    }
160
161    pub fn can_use_tool<F>(mut self, predicate: F) -> Self
162    where
163        F: Fn(&str, &crate::types::ToolArguments) -> bool + Send + Sync + 'static,
164    {
165        self.can_use_tool = Some(Arc::new(predicate));
166        self
167    }
168
169    pub fn idempotency_key(mut self, idempotency_key: Option<String>) -> Self {
170        self.idempotency_key = idempotency_key;
171        self
172    }
173
174    pub fn before_dispatch(mut self, callback: BeforeToolDispatch) -> Self {
175        self.before_dispatch = Some(callback);
176        self
177    }
178
179    pub fn lifecycle_callback(mut self, callback: ToolLifecycleCallback) -> Self {
180        self.lifecycle_callback = Some(callback);
181        self
182    }
183}
184
185#[derive(Clone, Default)]
186pub struct ToolOrchestrator {
187    tools: BTreeMap<String, Arc<dyn ToolExecutor>>,
188}
189
190impl ToolOrchestrator {
191    pub fn from_tools(tools: Vec<Arc<dyn ToolExecutor>>) -> Self {
192        let tools = tools
193            .into_iter()
194            .map(|tool| (tool.name().to_string(), tool))
195            .collect();
196        Self { tools }
197    }
198
199    pub async fn run_one(
200        &self,
201        call: ToolCall,
202        context: &mut ToolContext,
203        options: ToolRunOptions,
204    ) -> Result<ToolExecutionResult, crate::tools::ToolError> {
205        self.run_one_with_approval(call, context, options, |_call, _requirement, _context| None)
206            .await
207    }
208
209    pub(crate) async fn run_one_with_approval<F>(
210        &self,
211        call: ToolCall,
212        context: &mut ToolContext,
213        options: ToolRunOptions,
214        approval: F,
215    ) -> Result<ToolExecutionResult, crate::tools::ToolError>
216    where
217        F: FnOnce(&ToolCall, ApprovalRequirement, &ToolContext) -> Option<ToolExecutionResult>,
218    {
219        self.run_one_with_approval_and_metadata(
220            call,
221            context,
222            options,
223            |call, requirement, context, _metadata| approval(call, requirement, context),
224        )
225        .await
226    }
227
228    pub(crate) async fn run_one_with_approval_and_metadata<F>(
229        &self,
230        call: ToolCall,
231        context: &mut ToolContext,
232        options: ToolRunOptions,
233        approval: F,
234    ) -> Result<ToolExecutionResult, crate::tools::ToolError>
235    where
236        F: FnOnce(
237            &ToolCall,
238            ApprovalRequirement,
239            &ToolContext,
240            &Metadata,
241        ) -> Option<ToolExecutionResult>,
242    {
243        self.run_one_with_approval_and_metadata_deferred(call, context, options, approval)
244            .await
245            .map(DeferredToolExecution::complete)
246    }
247
248    pub(crate) async fn run_one_with_approval_and_metadata_deferred<F>(
249        &self,
250        call: ToolCall,
251        context: &mut ToolContext,
252        options: ToolRunOptions,
253        approval: F,
254    ) -> Result<DeferredToolExecution, crate::tools::ToolError>
255    where
256        F: FnOnce(
257            &ToolCall,
258            ApprovalRequirement,
259            &ToolContext,
260            &Metadata,
261        ) -> Option<ToolExecutionResult>,
262    {
263        if let Some(result) = crate::tools::dispatcher::argument_error_result(&call) {
264            return Ok(DeferredToolExecution::without_lifecycle(result));
265        }
266
267        let tool = self.tools.get(&call.name);
268        let tool_metadata = tool.and_then(|tool| tool.tool_metadata()).cloned();
269        emit_lifecycle(
270            options.lifecycle_callback.as_ref(),
271            ToolLifecycleEvent::Planned {
272                call: call.clone(),
273                tool_metadata: tool_metadata.clone(),
274            },
275        );
276
277        if let Some(tool) = tool {
278            if let Some(result) = tool.validate_arguments(&call)? {
279                return Ok(deferred_without_execution(
280                    &call,
281                    result,
282                    tool_metadata,
283                    options.lifecycle_callback.as_ref(),
284                ));
285            }
286        }
287
288        if let Some(allowed) = options.allowed_tools.as_ref() {
289            if !allowed.iter().any(|tool| tool == &call.name) {
290                return Ok(deferred_without_execution(
291                    &call,
292                    policy_denial(&call, "allowed_tools"),
293                    tool_metadata,
294                    options.lifecycle_callback.as_ref(),
295                ));
296            }
297        }
298        if options
299            .disallowed_tools
300            .iter()
301            .any(|tool| tool == &call.name)
302        {
303            return Ok(deferred_without_execution(
304                &call,
305                policy_denial(&call, "disallowed_tools"),
306                tool_metadata,
307                options.lifecycle_callback.as_ref(),
308            ));
309        }
310        if options
311            .can_use_tool
312            .as_ref()
313            .is_some_and(|predicate| !predicate(&call.name, &call.arguments))
314        {
315            return Ok(deferred_without_execution(
316                &call,
317                policy_denial(&call, "can_use_tool"),
318                tool_metadata,
319                options.lifecycle_callback.as_ref(),
320            ));
321        }
322        if options
323            .planned_tools
324            .as_ref()
325            .is_some_and(|planned| !planned.iter().any(|tool| tool == &call.name))
326        {
327            return Ok(deferred_without_execution(
328                &call,
329                policy_denial(&call, "planned_name"),
330                tool_metadata,
331                options.lifecycle_callback.as_ref(),
332            ));
333        }
334
335        let Some(tool) = tool else {
336            let result = tool_error(
337                &call,
338                "tool_not_found",
339                format!("Unknown tool: {}", call.name),
340                None,
341            );
342            return Ok(deferred_without_execution(
343                &call,
344                result,
345                None,
346                options.lifecycle_callback.as_ref(),
347            ));
348        };
349
350        if let Some(policy_source) = (ToolPolicy {
351            denied_side_effects: options.denied_side_effects.clone(),
352            denied_capability_tags: options.denied_capability_tags.clone(),
353            deny_terminal_tools: options.deny_terminal_tools,
354            denied_cost_dimensions: options.denied_cost_dimensions.clone(),
355            ..ToolPolicy::default()
356        })
357        .metadata_denial_source(tool_metadata.as_ref())
358        {
359            return Ok(deferred_without_execution(
360                &call,
361                policy_denial(&call, policy_source),
362                tool_metadata,
363                options.lifecycle_callback.as_ref(),
364            ));
365        }
366
367        context.begin_tool_call(&call);
368        context.idempotency_key = options.idempotency_key.clone();
369        let approval_requirement = match options.approval {
370            ApprovalPolicy::Never => ApprovalRequirement::NotRequired,
371            ApprovalPolicy::Always => ApprovalRequirement::Required,
372            ApprovalPolicy::Default | ApprovalPolicy::OnRequest => {
373                tool.approval_requirement(&call, &ToolRunContext::new(context))
374            }
375        };
376        let approval_result = approval(&call, approval_requirement, context, tool.metadata());
377        if let Some(mut result) = approval_result {
378            if result.tool_call_id.trim().is_empty() || result.tool_call_id == "pending" {
379                result.tool_call_id = call.id.clone();
380            }
381            return Ok(deferred_without_execution(
382                &call,
383                result,
384                tool_metadata,
385                options.lifecycle_callback.as_ref(),
386            ));
387        }
388
389        if let Some(callback) = options.before_dispatch.as_ref() {
390            callback(&call, context)?;
391        }
392
393        let started_at = Instant::now();
394        emit_lifecycle(
395            options.lifecycle_callback.as_ref(),
396            ToolLifecycleEvent::Started {
397                call: call.clone(),
398                tool_metadata: tool_metadata.clone(),
399            },
400        );
401        let future = tool.run(call.clone(), ToolRunContext::new(context));
402        let mut result = if let Some(timeout) = tool.timeout() {
403            match tokio::time::timeout(timeout, future).await {
404                Ok(result) => result?,
405                Err(_) => {
406                    let result = crate::tools::ToolOutput::error(format!(
407                        "Tool {} timed out after {} seconds.",
408                        call.name,
409                        timeout.as_secs_f64()
410                    ))
411                    .with_code("tool_timeout")
412                    .retryable(true)
413                    .to_result(&call.id);
414                    return Ok(DeferredToolExecution::with_lifecycle(
415                        call,
416                        result,
417                        true,
418                        Some(elapsed_millis(started_at)),
419                        tool_metadata,
420                        options.lifecycle_callback,
421                    ));
422                }
423            }
424        } else {
425            future.await?
426        };
427        if result.tool_call_id.trim().is_empty() || result.tool_call_id == "pending" {
428            result.tool_call_id = call.id.clone();
429        }
430        Ok(DeferredToolExecution::with_lifecycle(
431            call,
432            result,
433            true,
434            Some(elapsed_millis(started_at)),
435            tool_metadata,
436            options.lifecycle_callback,
437        ))
438    }
439
440    pub(crate) fn observe_result_without_execution(
441        &self,
442        call: ToolCall,
443        result: ToolExecutionResult,
444        options: &ToolRunOptions,
445    ) -> DeferredToolExecution {
446        if crate::tools::dispatcher::argument_error_result(&call).is_some() {
447            return DeferredToolExecution::without_lifecycle(result);
448        }
449        let tool_metadata = self
450            .tools
451            .get(&call.name)
452            .and_then(|tool| tool.tool_metadata())
453            .cloned();
454        emit_lifecycle(
455            options.lifecycle_callback.as_ref(),
456            ToolLifecycleEvent::Planned {
457                call: call.clone(),
458                tool_metadata: tool_metadata.clone(),
459            },
460        );
461        deferred_without_execution(
462            &call,
463            result,
464            tool_metadata,
465            options.lifecycle_callback.as_ref(),
466        )
467    }
468}
469
470fn deferred_without_execution(
471    call: &ToolCall,
472    result: ToolExecutionResult,
473    tool_metadata: Option<ToolMetadata>,
474    callback: Option<&ToolLifecycleCallback>,
475) -> DeferredToolExecution {
476    DeferredToolExecution::with_lifecycle(
477        call.clone(),
478        result,
479        false,
480        None,
481        tool_metadata,
482        callback.cloned(),
483    )
484}
485
486fn emit_completed(
487    call: &ToolCall,
488    result: &ToolExecutionResult,
489    execution_started: bool,
490    duration_ms: Option<u64>,
491    tool_metadata: Option<ToolMetadata>,
492    callback: Option<&ToolLifecycleCallback>,
493) {
494    emit_lifecycle(
495        callback,
496        ToolLifecycleEvent::Completed {
497            call: call.clone(),
498            result: result.clone(),
499            execution_started,
500            duration_ms,
501            tool_metadata,
502        },
503    );
504}
505
506fn emit_lifecycle(callback: Option<&ToolLifecycleCallback>, event: ToolLifecycleEvent) {
507    let Some(callback) = callback else {
508        return;
509    };
510    let _ = catch_unwind(AssertUnwindSafe(|| callback(event)));
511}
512
513fn elapsed_millis(started_at: Instant) -> u64 {
514    const JSON_SAFE_INTEGER_MAX: u128 = (1_u128 << 53) - 1;
515    started_at.elapsed().as_millis().min(JSON_SAFE_INTEGER_MAX) as u64
516}
517
518fn policy_denial(call: &ToolCall, policy_source: &str) -> ToolExecutionResult {
519    tool_error(
520        call,
521        "tool_not_allowed",
522        format!("Tool {} is not allowed for these arguments.", call.name),
523        Some(policy_source),
524    )
525}
526
527fn tool_error(
528    call: &ToolCall,
529    error_code: &str,
530    message: impl Into<String>,
531    policy_source: Option<&str>,
532) -> ToolExecutionResult {
533    let error_code = error_code.to_string();
534    let message = message.into();
535    let mut metadata = BTreeMap::new();
536    if let Some(policy_source) = policy_source {
537        metadata.insert("mode".to_string(), json!("permission_denied"));
538        metadata.insert("policy_source".to_string(), json!(policy_source));
539        metadata.insert("tool_name".to_string(), json!(call.name));
540        metadata.insert("arguments".to_string(), json!(call.arguments));
541        metadata.insert("message".to_string(), json!(message));
542    }
543    ToolExecutionResult {
544        tool_call_id: call.id.clone(),
545        content: json!({
546            "ok": false,
547            "error": message,
548            "error_code": error_code,
549            "tool_name": call.name,
550        })
551        .to_string(),
552        status: ToolResultStatus::Error,
553        directive: ToolDirective::Continue,
554        error_code: Some(error_code),
555        metadata,
556        image_url: None,
557        image_path: None,
558    }
559}
560
561#[cfg(test)]
562mod tests {
563    use std::sync::atomic::{AtomicUsize, Ordering};
564    use std::sync::Mutex;
565
566    use serde_json::{json, Value};
567
568    use super::*;
569    use crate::tools::{FunctionTool, ToolOutput};
570
571    #[tokio::test]
572    async fn metadata_aware_approval_callback_receives_effective_requirement() {
573        let tool = FunctionTool::builder("guarded")
574            .metadata("risk_level", json!("high"))
575            .needs_approval_if(|_context, _arguments| true)
576            .handler(|_context, _arguments: Value| async { Ok(ToolOutput::text("ran")) })
577            .build()
578            .expect("guarded tool");
579        let orchestrator = ToolOrchestrator::from_tools(vec![tool.to_executor()]);
580        let mut context = ToolContext::new("./workspace");
581
582        let result = orchestrator
583            .run_one_with_approval_and_metadata(
584                ToolCall::from_raw_arguments("guarded_call", "guarded", json!({})),
585                &mut context,
586                ToolRunOptions::default(),
587                |_call, requirement, _context, metadata| {
588                    assert_eq!(requirement, ApprovalRequirement::Required);
589                    assert_eq!(metadata["risk_level"], json!("high"));
590                    None
591                },
592            )
593            .await
594            .expect("tool result");
595
596        assert_eq!(result.status, ToolResultStatus::Success);
597    }
598
599    #[tokio::test]
600    async fn schema_invalid_arguments_fail_before_approval_or_execution() {
601        let approval_calls = Arc::new(AtomicUsize::new(0));
602        let handler_calls = Arc::new(AtomicUsize::new(0));
603        let observed_approval = approval_calls.clone();
604        let observed_handler = handler_calls.clone();
605        let tool = FunctionTool::builder("guarded")
606            .json_schema(json!({
607                "type": "object",
608                "properties": {"value": {"type": "integer"}},
609                "required": ["value"],
610                "additionalProperties": false,
611            }))
612            .needs_approval_if(move |_context, _arguments| {
613                observed_approval.fetch_add(1, Ordering::SeqCst);
614                true
615            })
616            .handler(move |_context, _arguments: Value| {
617                let observed_handler = observed_handler.clone();
618                async move {
619                    observed_handler.fetch_add(1, Ordering::SeqCst);
620                    Ok(ToolOutput::text("ran"))
621                }
622            })
623            .build()
624            .expect("guarded tool");
625        let events = Arc::new(Mutex::new(Vec::new()));
626        let observed_events = events.clone();
627        let options = ToolRunOptions::default().lifecycle_callback(Arc::new(move |event| {
628            let name = match event {
629                ToolLifecycleEvent::Planned { .. } => "planned",
630                ToolLifecycleEvent::Started { .. } => "started",
631                ToolLifecycleEvent::Completed { .. } => "completed",
632            };
633            observed_events.lock().expect("events").push(name);
634        }));
635        let orchestrator = ToolOrchestrator::from_tools(vec![tool.to_executor()]);
636        let mut context = ToolContext::new("./workspace");
637
638        let result = orchestrator
639            .run_one(
640                ToolCall::from_raw_arguments(
641                    "guarded_call",
642                    "guarded",
643                    json!({"value": "wrong", "extra": true}),
644                ),
645                &mut context,
646                options,
647            )
648            .await
649            .expect("tool result");
650
651        assert_eq!(
652            result.error_code.as_deref(),
653            Some(crate::tools::argument_validation::INVALID_TOOL_ARGUMENTS_ERROR_CODE)
654        );
655        assert_eq!(approval_calls.load(Ordering::SeqCst), 0);
656        assert_eq!(handler_calls.load(Ordering::SeqCst), 0);
657        assert_eq!(
658            events.lock().expect("events").as_slice(),
659            ["planned", "completed"]
660        );
661    }
662}