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: Box<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: Box::new(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        truncated: false,
559        truncation_reason: None,
560        original_bytes: None,
561        visible_bytes: None,
562        artifact: None,
563        cursor: None,
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use std::sync::atomic::{AtomicUsize, Ordering};
570    use std::sync::Mutex;
571
572    use serde_json::{json, Value};
573
574    use super::*;
575    use crate::tools::{FunctionTool, ToolOutput};
576
577    #[tokio::test]
578    async fn metadata_aware_approval_callback_receives_effective_requirement() {
579        let tool = FunctionTool::builder("guarded")
580            .metadata("risk_level", json!("high"))
581            .needs_approval_if(|_context, _arguments| true)
582            .handler(|_context, _arguments: Value| async { Ok(ToolOutput::text("ran")) })
583            .build()
584            .expect("guarded tool");
585        let orchestrator = ToolOrchestrator::from_tools(vec![tool.to_executor()]);
586        let mut context = ToolContext::new("./workspace");
587
588        let result = orchestrator
589            .run_one_with_approval_and_metadata(
590                ToolCall::from_raw_arguments("guarded_call", "guarded", json!({})),
591                &mut context,
592                ToolRunOptions::default(),
593                |_call, requirement, _context, metadata| {
594                    assert_eq!(requirement, ApprovalRequirement::Required);
595                    assert_eq!(metadata["risk_level"], json!("high"));
596                    None
597                },
598            )
599            .await
600            .expect("tool result");
601
602        assert_eq!(result.status, ToolResultStatus::Success);
603    }
604
605    #[tokio::test]
606    async fn schema_invalid_arguments_fail_before_approval_or_execution() {
607        let approval_calls = Arc::new(AtomicUsize::new(0));
608        let handler_calls = Arc::new(AtomicUsize::new(0));
609        let observed_approval = approval_calls.clone();
610        let observed_handler = handler_calls.clone();
611        let tool = FunctionTool::builder("guarded")
612            .json_schema(json!({
613                "type": "object",
614                "properties": {"value": {"type": "integer"}},
615                "required": ["value"],
616                "additionalProperties": false,
617            }))
618            .needs_approval_if(move |_context, _arguments| {
619                observed_approval.fetch_add(1, Ordering::SeqCst);
620                true
621            })
622            .handler(move |_context, _arguments: Value| {
623                let observed_handler = observed_handler.clone();
624                async move {
625                    observed_handler.fetch_add(1, Ordering::SeqCst);
626                    Ok(ToolOutput::text("ran"))
627                }
628            })
629            .build()
630            .expect("guarded tool");
631        let events = Arc::new(Mutex::new(Vec::new()));
632        let observed_events = events.clone();
633        let options = ToolRunOptions::default().lifecycle_callback(Arc::new(move |event| {
634            let name = match event {
635                ToolLifecycleEvent::Planned { .. } => "planned",
636                ToolLifecycleEvent::Started { .. } => "started",
637                ToolLifecycleEvent::Completed { .. } => "completed",
638            };
639            observed_events.lock().expect("events").push(name);
640        }));
641        let orchestrator = ToolOrchestrator::from_tools(vec![tool.to_executor()]);
642        let mut context = ToolContext::new("./workspace");
643
644        let result = orchestrator
645            .run_one(
646                ToolCall::from_raw_arguments(
647                    "guarded_call",
648                    "guarded",
649                    json!({"value": "wrong", "extra": true}),
650                ),
651                &mut context,
652                options,
653            )
654            .await
655            .expect("tool result");
656
657        assert_eq!(
658            result.error_code.as_deref(),
659            Some(crate::tools::argument_validation::INVALID_TOOL_ARGUMENTS_ERROR_CODE)
660        );
661        assert_eq!(approval_calls.load(Ordering::SeqCst), 0);
662        assert_eq!(handler_calls.load(Ordering::SeqCst), 0);
663        assert_eq!(
664            events.lock().expect("events").as_slice(),
665            ["planned", "completed"]
666        );
667    }
668}