1use std::collections::BTreeMap;
4use std::collections::BTreeSet;
5use std::panic::AssertUnwindSafe;
6use std::sync::Arc;
7
8use bm25::Document;
9use bm25::Language;
10use bm25::SearchEngineBuilder;
11use diffy::Patch;
12use futures_util::FutureExt;
13use futures_util::future::join_all;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16
17use super::manifest::MiddlewareManifest;
18use super::{Middleware, PromptSection};
19use crate::BoxFuture;
20use crate::Error;
21use crate::Result;
22use crate::backend::model::TOOLS_SEARCH_NAME;
23use crate::backend::model::ToolCall;
24use crate::backend::model::ToolDefinition;
25use crate::backend::model::ToolLoad;
26#[cfg(test)]
27use crate::backend::sandbox::BackgroundCommandPoll;
28use crate::backend::sandbox::Sandbox;
29use crate::backend::sandbox::SandboxPermissions;
30use crate::backend::sandbox::ToolPermissions;
31use crate::preview_json;
32use crate::protocol::EventMsg;
33use crate::protocol::FrontendBlock;
34use crate::protocol::FrontendBlockFormat;
35use crate::protocol::FrontendBlockRole;
36use crate::protocol::FrontendBlockState;
37use crate::protocol::FrontendBlockUpdate;
38use crate::protocol::FrontendContribution;
39use crate::protocol::FrontendTone;
40use crate::protocol::ToolLoadEvent;
41
42mod text {
43 include!(concat!(env!("OUT_DIR"), "/src_middleware_tools_text.rs"));
44}
45
46mod coding;
47mod commands;
48mod patch;
49
50#[cfg(test)]
51use coding::ApplyPatchArgs;
52use coding::{ApplyPatch, ReadFile, WriteFile};
53#[cfg(test)]
54use commands::background_output;
55use commands::{Bash, PollCommand, StartCommand, StopCommand};
56#[cfg(test)]
57use patch::{apply_patch_document, parse_patch_document, validate_patch_complexity};
58
59const MAX_TOOL_OUTPUT_BYTES: usize = 40_000;
60const MAX_TOOL_UI_BYTES: usize = 512;
61const MAX_TOOL_UI_LINES: usize = 5;
62const MAX_TOOL_NAME_BYTES: usize = 256;
63const MAX_TOOL_SEARCH_QUERY_BYTES: usize = 512;
64const MAX_TOOL_SEARCH_RESULTS: usize = 8;
65const MAX_MUTATION_BYTES: usize = 40_000;
66const MAX_COMMAND_BYTES: usize = 8_000;
67const MAX_PATCH_MATCH_WORK: usize = 32 * 1024 * 1024;
68
69pub const MANIFEST: MiddlewareManifest = MiddlewareManifest {
71 id: "tools",
72 label: text::MANIFEST_LABEL,
73 description: text::MANIFEST_DESCRIPTION,
74 required: true,
75 default_enabled: true,
76 settings: &[],
77};
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ExecutionMode {
82 Parallel,
83 Exclusive,
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ApprovalRequirement {
89 Never,
90 Always,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ToolExposure {
96 Direct,
98 Deferred,
100 Hidden,
102}
103
104pub struct ToolContext {
106 pub sandbox: Arc<Sandbox>,
107 pub permissions: ToolPermissions,
108 pub turn_id: String,
109}
110
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct HookIdentity {
114 pub name: &'static str,
116 pub subjects: &'static [&'static str],
118}
119
120pub trait Tool: Send + Sync {
122 fn definition(&self) -> ToolDefinition;
124
125 fn exposure(&self) -> ToolExposure {
127 ToolExposure::Deferred
128 }
129
130 fn execution_mode(&self) -> ExecutionMode {
132 ExecutionMode::Exclusive
133 }
134
135 fn approval(&self) -> ApprovalRequirement {
137 ApprovalRequirement::Never
138 }
139
140 fn cancel_on_input(&self) -> bool {
142 false
143 }
144
145 fn hook_identity(&self) -> Option<HookIdentity> {
147 None
148 }
149
150 fn hook_input(&self, arguments: &Value) -> Value {
152 arguments.clone()
153 }
154
155 fn rewrite_hook_input(&self, input: Value) -> Result<Value> {
157 object_hook_input(input)
158 }
159
160 fn call<'a>(&'a self, context: ToolContext, arguments: Value) -> BoxFuture<'a, Result<String>>;
162}
163
164#[derive(Debug, Clone, PartialEq)]
165pub(crate) struct HookTool {
166 pub(crate) name: String,
167 pub(crate) input: Value,
168 pub(crate) subjects: Vec<String>,
169}
170
171#[derive(Clone)]
172struct RegisteredTool {
173 definition: ToolDefinition,
174 exposure: ToolExposure,
175 execution_mode: ExecutionMode,
176 approval: ApprovalRequirement,
177 cancel_on_input: bool,
178 handler: RegisteredHandler,
179}
180
181#[derive(Clone)]
182enum RegisteredHandler {
183 Tool(Arc<dyn Tool>),
184 Search,
185}
186
187#[derive(Clone, Default)]
189pub struct Catalog {
190 tools: BTreeMap<String, RegisteredTool>,
191 registered_definitions: Arc<[ToolDefinition]>,
192 direct_definitions: Arc<[ToolDefinition]>,
193 deferred_definitions: Arc<[ToolDefinition]>,
194 revision: String,
195 finalized: bool,
196}
197
198#[derive(Clone)]
200pub(crate) struct PreparedToolSet {
201 direct: Vec<ToolDefinition>,
202 deferred: Vec<ToolDefinition>,
203 available: BTreeSet<String>,
204 searchable: BTreeSet<String>,
205 materialized: BTreeSet<String>,
206 catalog_revision: String,
207}
208
209#[derive(Default)]
210pub(crate) struct ToolEffects {
211 pub(crate) input: Vec<Value>,
212 pub(crate) events: Vec<EventMsg>,
213}
214
215impl PreparedToolSet {
216 pub(crate) fn direct(&self) -> &[ToolDefinition] {
217 &self.direct
218 }
219
220 pub(crate) fn deferred(&self) -> &[ToolDefinition] {
221 &self.deferred
222 }
223
224 pub(crate) fn materialized(&self) -> &BTreeSet<String> {
225 &self.materialized
226 }
227
228 pub(crate) fn accept_materialized(
229 &mut self,
230 tools: &BTreeSet<String>,
231 turn_id: &str,
232 load_id: &str,
233 ) -> Result<ToolEffects> {
234 if !tools.is_subset(&self.searchable) {
235 return Err(Error::Provider(
236 "provider materialized a tool outside the searchable catalog".into(),
237 ));
238 }
239 self.materialized.extend(tools.iter().cloned());
240 materialization_effects(
241 &self.catalog_revision,
242 tools.iter().cloned(),
243 turn_id,
244 load_id,
245 )
246 }
247}
248
249impl Catalog {
250 pub fn register(&mut self, tool: Arc<dyn Tool>) -> Result<()> {
252 if self.finalized {
253 return Err(Error::Config("tool catalog is already finalized".into()));
254 }
255 let definition = tool.definition();
256 validate_definition(&definition)?;
257 if definition.name == TOOLS_SEARCH_NAME {
258 return Err(Error::Config(format!(
259 "tool name `{TOOLS_SEARCH_NAME}` is reserved"
260 )));
261 }
262 let name = definition.name.clone();
263 let entry = RegisteredTool {
264 definition,
265 exposure: tool.exposure(),
266 execution_mode: tool.execution_mode(),
267 approval: tool.approval(),
268 cancel_on_input: tool.cancel_on_input(),
269 handler: RegisteredHandler::Tool(tool),
270 };
271 self.insert(name, entry)
272 }
273
274 pub fn finalize(&mut self) -> Result<()> {
276 if self.finalized {
277 return Err(Error::Config("tool catalog is already finalized".into()));
278 }
279 if !self.deferred_definitions.is_empty() {
280 let definition = tools_search_definition();
281 let name = definition.name.clone();
282 self.insert(
283 name,
284 RegisteredTool {
285 definition,
286 exposure: ToolExposure::Direct,
287 execution_mode: ExecutionMode::Exclusive,
288 approval: ApprovalRequirement::Never,
289 cancel_on_input: false,
290 handler: RegisteredHandler::Search,
291 },
292 )?;
293 }
294 self.revision = catalog_revision(&self.tools);
295 self.finalized = true;
296 Ok(())
297 }
298
299 fn insert(&mut self, name: String, entry: RegisteredTool) -> Result<()> {
300 if self.tools.contains_key(&name) {
301 return Err(Error::Duplicate(format!("tool `{name}`")));
302 }
303 self.tools.insert(name, entry);
304 self.registered_definitions = self
305 .tools
306 .values()
307 .map(|tool| tool.definition.clone())
308 .collect::<Vec<_>>()
309 .into();
310 self.direct_definitions = self
311 .tools
312 .values()
313 .filter(|tool| tool.exposure == ToolExposure::Direct)
314 .map(|tool| tool.definition.clone())
315 .collect::<Vec<_>>()
316 .into();
317 self.deferred_definitions = self
318 .tools
319 .values()
320 .filter(|tool| tool.exposure == ToolExposure::Deferred)
321 .map(|tool| tool.definition.clone())
322 .collect::<Vec<_>>()
323 .into();
324 Ok(())
325 }
326
327 #[must_use]
329 pub fn registered_definitions(&self) -> Arc<[ToolDefinition]> {
330 Arc::clone(&self.registered_definitions)
331 }
332
333 #[must_use]
335 pub fn direct_definitions(&self) -> Arc<[ToolDefinition]> {
336 Arc::clone(&self.direct_definitions)
337 }
338
339 #[must_use]
341 pub fn deferred_definitions(&self) -> Arc<[ToolDefinition]> {
342 Arc::clone(&self.deferred_definitions)
343 }
344
345 pub fn revision(&self) -> Result<&str> {
347 if self.finalized {
348 Ok(&self.revision)
349 } else {
350 Err(Error::Config(
351 "tool catalog must be finalized before reading its revision".into(),
352 ))
353 }
354 }
355
356 pub(crate) fn exposed_names(&self) -> BTreeSet<String> {
357 self.direct_definitions
358 .iter()
359 .chain(self.deferred_definitions.iter())
360 .map(|tool| tool.name.clone())
361 .collect()
362 }
363
364 pub(crate) fn prepare(
365 &self,
366 input: &[Value],
367 mut available: BTreeSet<String>,
368 ) -> Result<PreparedToolSet> {
369 let deferred = self
370 .deferred_definitions
371 .iter()
372 .filter(|tool| available.contains(&tool.name))
373 .cloned()
374 .collect::<Vec<_>>();
375 let searchable = deferred
376 .iter()
377 .map(|tool| tool.name.clone())
378 .collect::<BTreeSet<_>>();
379 if searchable.is_empty() {
380 available.remove(TOOLS_SEARCH_NAME);
381 }
382 let materialized = loaded_tools(input, self.revision()?, &searchable)?;
383 let direct = self
384 .direct_definitions
385 .iter()
386 .filter(|tool| available.contains(&tool.name))
387 .cloned()
388 .collect();
389 Ok(PreparedToolSet {
390 direct,
391 deferred,
392 available,
393 searchable,
394 materialized,
395 catalog_revision: self.revision()?.into(),
396 })
397 }
398
399 pub(crate) fn bind_prepared(
400 &self,
401 call: ToolCall,
402 tools: &PreparedToolSet,
403 ) -> Result<BoundToolCall> {
404 if !tools.available.contains(&call.name) {
405 return Err(Error::Tool(format!(
406 "tool `{}` is unavailable for this model step",
407 call.name
408 )));
409 }
410 self.bind_call(call, &tools.materialized, &tools.searchable)
411 }
412
413 pub(crate) fn bind_live_batch(
414 &self,
415 calls: &[ToolCall],
416 tools: &PreparedToolSet,
417 ) -> (Vec<BoundToolCall>, Vec<ToolResult>) {
418 let mut bound = Vec::with_capacity(calls.len());
419 let mut rejected = Vec::new();
420 for call in calls {
421 match self.bind_prepared(call.clone(), tools) {
422 Ok(call) => bound.push(call),
423 Err(error) => rejected.push(ToolResult::error(call, error.to_string())),
424 }
425 }
426 (bound, rejected)
427 }
428
429 pub fn search_deferred(
431 &self,
432 query: &str,
433 searchable: &BTreeSet<String>,
434 ) -> Result<Vec<ToolDefinition>> {
435 let query = query.trim();
436 if query.is_empty() {
437 return Err(Error::Tool("tools_search query cannot be empty".into()));
438 }
439 if query.len() > MAX_TOOL_SEARCH_QUERY_BYTES {
440 return Err(Error::Tool(format!(
441 "tools_search query exceeds {MAX_TOOL_SEARCH_QUERY_BYTES} bytes"
442 )));
443 }
444 let definitions = self
445 .tools
446 .values()
447 .filter(|tool| {
448 tool.exposure == ToolExposure::Deferred
449 && searchable.contains(&tool.definition.name)
450 })
451 .map(|tool| &tool.definition)
452 .collect::<Vec<_>>();
453 if definitions.is_empty() {
454 return Ok(Vec::new());
455 }
456 let documents = definitions
458 .iter()
459 .enumerate()
460 .map(|(index, definition)| Document::new(index, tool_search_text(definition)))
461 .collect::<Vec<_>>();
462 let search_engine =
463 SearchEngineBuilder::<usize>::with_documents(Language::English, documents).build();
464
465 Ok(search_engine
466 .search(query, MAX_TOOL_SEARCH_RESULTS)
467 .into_iter()
468 .filter_map(|result| definitions.get(result.document.id).copied().cloned())
469 .collect())
470 }
471
472 pub fn bind_call(
474 &self,
475 call: ToolCall,
476 materialized: &BTreeSet<String>,
477 searchable: &BTreeSet<String>,
478 ) -> Result<BoundToolCall> {
479 if !self.finalized {
480 return Err(Error::Config(
481 "tool catalog must be finalized before binding calls".into(),
482 ));
483 }
484 let Some(tool) = self.get(&call.name) else {
485 return Err(Error::Tool(format!("unknown tool `{}`", call.name)));
486 };
487 let materialized = match tool.exposure {
488 ToolExposure::Direct => false,
489 ToolExposure::Deferred if !searchable.contains(&call.name) => {
490 return Err(Error::Tool(format!(
491 "tool `{}` is not available for this model step",
492 call.name
493 )));
494 }
495 ToolExposure::Deferred if materialized.contains(&call.name) => true,
496 ToolExposure::Deferred => {
497 return Err(Error::Tool(format!(
498 "tool `{}` was not materialized for this model step",
499 call.name
500 )));
501 }
502 ToolExposure::Hidden => {
503 return Err(Error::Tool(format!(
504 "tool `{}` is hidden from the model",
505 call.name
506 )));
507 }
508 };
509 let search_scope =
510 matches!(&tool.handler, RegisteredHandler::Search).then(|| searchable.clone());
511 Ok(BoundToolCall {
512 call,
513 materialized,
514 search_scope,
515 })
516 }
517
518 #[must_use]
520 pub fn requires_approval(&self, name: &str) -> bool {
521 self.tools
522 .get(name)
523 .is_some_and(|tool| tool.approval == ApprovalRequirement::Always)
524 }
525
526 pub(crate) fn cancels_on_input(&self, calls: &[ToolCall]) -> bool {
527 !calls.is_empty()
528 && calls.iter().all(|call| {
529 self.tools
530 .get(&call.name)
531 .is_some_and(|tool| tool.cancel_on_input)
532 })
533 }
534
535 pub(crate) fn hook_tool(&self, call: &ToolCall, description: Option<&str>) -> HookTool {
536 let registered = self.get(&call.name);
537 let handler = registered.and_then(|tool| match &tool.handler {
538 RegisteredHandler::Tool(handler) => Some(handler),
539 RegisteredHandler::Search => None,
540 });
541 let identity = handler.and_then(|handler| handler.hook_identity());
542 let name = identity.map_or_else(|| call.name.clone(), |identity| identity.name.into());
543 let subjects = identity.map_or_else(
544 || vec![call.name.clone()],
545 |identity| {
546 identity
547 .subjects
548 .iter()
549 .map(|subject| (*subject).into())
550 .collect()
551 },
552 );
553 let mut input = handler.map_or_else(
554 || call.arguments.clone(),
555 |handler| handler.hook_input(&call.arguments),
556 );
557 if let Some(description) = description
558 && let Some(input) = input.as_object_mut()
559 {
560 input
561 .entry("description")
562 .or_insert_with(|| Value::String(description.into()));
563 }
564 HookTool {
565 name,
566 input,
567 subjects,
568 }
569 }
570
571 pub(crate) fn rewrite_hook_input(&self, name: &str, input: Value) -> Result<Value> {
572 match self.get(name).map(|tool| &tool.handler) {
573 Some(RegisteredHandler::Tool(handler)) => handler.rewrite_hook_input(input),
574 Some(RegisteredHandler::Search) | None => object_hook_input(input),
575 }
576 }
577
578 fn get(&self, name: &str) -> Option<&RegisteredTool> {
579 self.tools.get(name)
580 }
581}
582
583fn tool_search_text(definition: &ToolDefinition) -> String {
584 let mut parts = Vec::new();
585 push_search_part(&mut parts, &definition.name);
586 push_search_part(&mut parts, &definition.name.replace('_', " "));
587 push_search_part(&mut parts, &definition.description);
588 append_schema_search_text(&definition.parameters, &mut parts);
589 parts.join(" ")
590}
591
592fn append_schema_search_text(schema: &Value, parts: &mut Vec<String>) {
593 if let Some(description) = schema.get("description").and_then(Value::as_str) {
594 push_search_part(parts, description);
595 }
596 if let Some(properties) = schema.get("properties").and_then(Value::as_object) {
597 for (name, schema) in properties {
598 push_search_part(parts, name);
599 append_schema_search_text(schema, parts);
600 }
601 }
602 if let Some(items) = schema.get("items") {
603 append_schema_search_text(items, parts);
604 }
605 if let Some(variants) = schema.get("anyOf").and_then(Value::as_array) {
606 for variant in variants {
607 append_schema_search_text(variant, parts);
608 }
609 }
610}
611
612fn push_search_part(parts: &mut Vec<String>, part: &str) {
613 let part = part.trim();
614 if !part.is_empty() {
615 parts.push(part.into());
616 }
617}
618
619fn catalog_revision(tools: &BTreeMap<String, RegisteredTool>) -> String {
620 let mut hasher = Sha256::new();
621 for tool in tools.values() {
622 hasher.update([match tool.exposure {
623 ToolExposure::Direct => 0,
624 ToolExposure::Deferred => 1,
625 ToolExposure::Hidden => 2,
626 }]);
627 hash_field(&mut hasher, tool.definition.name.as_bytes());
628 hash_field(&mut hasher, tool.definition.description.as_bytes());
629 hash_field(
630 &mut hasher,
631 tool.definition.parameters.to_string().as_bytes(),
632 );
633 }
634 format!("{:x}", hasher.finalize())
635}
636
637fn hash_field(hasher: &mut Sha256, value: &[u8]) {
638 hasher.update((value.len() as u64).to_le_bytes());
639 hasher.update(value);
640}
641
642fn loaded_tools(
643 input: &[Value],
644 catalog_revision: &str,
645 searchable: &BTreeSet<String>,
646) -> Result<BTreeSet<String>> {
647 let mut loaded = BTreeSet::new();
648 for item in input {
649 let Some(selection) = ToolLoad::from_input(item)? else {
650 continue;
651 };
652 if selection.catalog_revision == catalog_revision {
653 loaded.extend(
654 selection
655 .tools
656 .into_iter()
657 .filter(|name| searchable.contains(name)),
658 );
659 }
660 }
661 Ok(loaded)
662}
663
664fn materialization_effects(
665 catalog_revision: &str,
666 tools: impl IntoIterator<Item = String>,
667 turn_id: &str,
668 load_id: &str,
669) -> Result<ToolEffects> {
670 let tools = tools.into_iter().collect::<Vec<_>>();
671 if tools.is_empty() {
672 return Ok(ToolEffects::default());
673 }
674 let load = ToolLoad {
675 catalog_revision: catalog_revision.into(),
676 tools,
677 };
678 Ok(ToolEffects {
679 input: vec![load.clone().into_input()],
680 events: vec![EventMsg::ToolLoad(ToolLoadEvent {
681 turn_id: turn_id.into(),
682 load_id: load_id.into(),
683 catalog_revision: load.catalog_revision,
684 tools: load.tools,
685 })],
686 })
687}
688
689#[derive(Debug, Clone, PartialEq)]
691pub struct BoundToolCall {
692 call: ToolCall,
693 materialized: bool,
694 search_scope: Option<BTreeSet<String>>,
695}
696
697impl BoundToolCall {
698 #[must_use]
700 pub fn as_call(&self) -> &ToolCall {
701 &self.call
702 }
703
704 #[must_use]
706 pub fn into_call(self) -> ToolCall {
707 self.call
708 }
709}
710
711#[must_use]
713pub fn tools_search_definition() -> ToolDefinition {
714 ToolDefinition {
715 name: TOOLS_SEARCH_NAME.into(),
716 description: "Find currently available tools by name or description and load matching tools for this session.".into(),
717 parameters: serde_json::json!({
718 "type": "object",
719 "properties": {
720 "query": {
721 "type": "string",
722 "minLength": 1,
723 "maxLength": MAX_TOOL_SEARCH_QUERY_BYTES
724 }
725 },
726 "required": ["query"],
727 "additionalProperties": false
728 }),
729 }
730}
731
732fn validate_definition(definition: &ToolDefinition) -> Result<()> {
733 if definition.name.trim().is_empty() {
734 return Err(Error::Config("tool name cannot be empty".into()));
735 }
736 if definition.name.len() > MAX_TOOL_NAME_BYTES {
737 return Err(Error::Config(format!(
738 "tool name exceeds {MAX_TOOL_NAME_BYTES} bytes"
739 )));
740 }
741 if !definition.parameters.is_object() {
742 return Err(Error::Config(format!(
743 "tool `{}` parameters must be a JSON object",
744 definition.name
745 )));
746 }
747 Ok(())
748}
749
750fn object_hook_input(input: Value) -> Result<Value> {
751 if input.is_object() {
752 Ok(input)
753 } else {
754 Err(Error::Config(
755 "hook tool rewrite must be a JSON object".into(),
756 ))
757 }
758}
759
760#[derive(Debug, Clone, PartialEq)]
762pub struct ToolResult {
763 pub call_id: String,
764 pub name: String,
765 pub output: String,
766 pub is_error: bool,
767 pub(crate) handler_executed: bool,
768 pub(crate) additional_input: Vec<Value>,
769 pub(crate) events: Vec<EventMsg>,
770}
771
772impl ToolResult {
773 pub(crate) fn error(call: &ToolCall, output: impl AsRef<str>) -> Self {
774 Self {
775 call_id: call.call_id.clone(),
776 name: call.name.clone(),
777 output: capped(output.as_ref(), MAX_TOOL_OUTPUT_BYTES),
778 is_error: true,
779 handler_executed: false,
780 additional_input: Vec::new(),
781 events: Vec::new(),
782 }
783 }
784
785 pub(crate) fn replace(&mut self, output: impl AsRef<str>) {
786 self.output = capped(output.as_ref(), MAX_TOOL_OUTPUT_BYTES);
787 }
788}
789
790pub(crate) async fn execute_batch(
793 catalog: &Catalog,
794 calls: &[BoundToolCall],
795 sandbox: Arc<Sandbox>,
796 permissions: &SandboxPermissions,
797 turn_id: &str,
798) -> Vec<ToolResult> {
799 let mut results = Vec::with_capacity(calls.len());
800 let mut index = 0;
801 while index < calls.len() {
802 if is_parallel(catalog, &calls[index]) {
803 let end = calls[index..]
804 .iter()
805 .position(|call| !is_parallel(catalog, call))
806 .map_or(calls.len(), |offset| index + offset);
807 results.extend(
809 join_all(
810 calls[index..end]
811 .iter()
812 .cloned()
813 .map(|call| execute_call(catalog, call, &sandbox, permissions, turn_id)),
814 )
815 .await,
816 );
817 index = end;
818 } else {
819 results.push(
820 execute_call(
821 catalog,
822 calls[index].clone(),
823 &sandbox,
824 permissions,
825 turn_id,
826 )
827 .await,
828 );
829 index += 1;
830 }
831 }
832 results
833}
834
835fn is_parallel(catalog: &Catalog, call: &BoundToolCall) -> bool {
836 catalog
837 .get(&call.as_call().name)
838 .is_some_and(|tool| tool.execution_mode == ExecutionMode::Parallel)
839}
840
841async fn execute_call(
842 catalog: &Catalog,
843 call: BoundToolCall,
844 sandbox: &Arc<Sandbox>,
845 permissions: &SandboxPermissions,
846 turn_id: &str,
847) -> ToolResult {
848 let BoundToolCall {
849 call,
850 materialized,
851 search_scope,
852 } = call;
853 let context = ToolContext {
854 sandbox: Arc::clone(sandbox),
855 permissions: permissions.for_call(&call.call_id),
856 turn_id: turn_id.into(),
857 };
858 let Some(tool) = catalog.get(&call.name).cloned() else {
859 return ToolResult::error(&call, format!("unknown tool `{}`", call.name));
860 };
861 let catalog_revision = match catalog.revision() {
862 Ok(revision) => revision.to_owned(),
863 Err(error) => return ToolResult::error(&call, error.to_string()),
864 };
865 match tool.exposure {
866 ToolExposure::Direct => {}
867 ToolExposure::Deferred if materialized => {}
868 ToolExposure::Deferred => {
869 return ToolResult::error(
870 &call,
871 format!(
872 "tool `{}` was not materialized for this model step",
873 call.name
874 ),
875 );
876 }
877 ToolExposure::Hidden => {
878 return ToolResult::error(
879 &call,
880 format!("tool `{}` is hidden from the model", call.name),
881 );
882 }
883 }
884 if tool.approval == ApprovalRequirement::Always && !context.permissions.allows_mutation() {
885 return ToolResult::error(&call, "tool call is not authorized to mutate state");
886 }
887 let ToolCall {
888 call_id,
889 name,
890 arguments,
891 } = call;
892 let search_catalog = catalog.clone();
893 let result = AssertUnwindSafe(async move {
894 match tool.handler {
895 RegisteredHandler::Tool(handler) => handler
896 .call(context, arguments)
897 .await
898 .map(ToolOutput::content),
899 RegisteredHandler::Search => {
900 let Some(search_scope) = search_scope else {
901 return Err(Error::Tool("tools_search scope is unavailable".into()));
902 };
903 tools_search(&search_catalog, arguments, &search_scope)
904 }
905 }
906 })
907 .catch_unwind()
908 .await;
909 match result {
910 Ok(Ok(output)) => {
911 match materialization_effects(&catalog_revision, output.loaded_tools, turn_id, &call_id)
912 {
913 Ok(effects) => ToolResult {
914 call_id,
915 name,
916 output: capped(&output.content, MAX_TOOL_OUTPUT_BYTES),
917 is_error: false,
918 handler_executed: true,
919 additional_input: effects.input,
920 events: effects.events,
921 },
922 Err(error) => ToolResult {
923 call_id,
924 name,
925 output: capped(&error.to_string(), MAX_TOOL_OUTPUT_BYTES),
926 is_error: true,
927 handler_executed: true,
928 additional_input: Vec::new(),
929 events: Vec::new(),
930 },
931 }
932 }
933 Ok(Err(error)) => ToolResult {
934 call_id,
935 name,
936 output: capped(&error.to_string(), MAX_TOOL_OUTPUT_BYTES),
937 is_error: true,
938 handler_executed: true,
939 additional_input: Vec::new(),
940 events: Vec::new(),
941 },
942 Err(_) => ToolResult {
943 call_id,
944 name,
945 output: "tool panicked".into(),
946 is_error: true,
947 handler_executed: true,
948 additional_input: Vec::new(),
949 events: Vec::new(),
950 },
951 }
952}
953
954struct ToolOutput {
955 content: String,
956 loaded_tools: Vec<String>,
957}
958
959impl ToolOutput {
960 fn content(content: String) -> Self {
961 Self {
962 content,
963 loaded_tools: Vec::new(),
964 }
965 }
966}
967
968fn tools_search(
969 catalog: &Catalog,
970 arguments: Value,
971 searchable: &BTreeSet<String>,
972) -> Result<ToolOutput> {
973 let Some(arguments) = arguments.as_object() else {
974 return Err(Error::Tool(
975 "tools_search arguments must be an object".into(),
976 ));
977 };
978 if arguments.len() != 1 {
979 return Err(Error::Tool(
980 "tools_search accepts only the `query` argument".into(),
981 ));
982 }
983 let query = arguments
984 .get("query")
985 .and_then(Value::as_str)
986 .ok_or_else(|| Error::Tool("tools_search requires a string `query`".into()))?;
987 let loaded_tools = catalog
988 .search_deferred(query, searchable)?
989 .into_iter()
990 .map(|definition| definition.name)
991 .collect::<Vec<_>>();
992 let content = serde_json::to_string(&serde_json::json!({
993 "loaded_tools": &loaded_tools
994 }))?;
995 Ok(ToolOutput {
996 content,
997 loaded_tools,
998 })
999}
1000
1001fn capped(output: &str, limit: usize) -> String {
1002 if output.len() <= limit {
1003 return output.to_string();
1004 }
1005
1006 let left_budget = limit / 2;
1007 let right_budget = limit - left_budget;
1008 let left = crate::truncate_utf8(output, left_budget);
1009 let mut right_start = output.len() - right_budget;
1010 while !output.is_char_boundary(right_start) {
1011 right_start += 1;
1012 }
1013 let removed = output[left.len()..right_start].chars().count();
1014 format!(
1015 "{}…{removed} chars truncated…{}",
1016 left,
1017 &output[right_start..]
1018 )
1019}
1020
1021fn compact_output(output: &str) -> String {
1022 let total_lines = output.lines().count();
1023 if output.len() <= MAX_TOOL_UI_BYTES && total_lines <= MAX_TOOL_UI_LINES {
1024 return output.to_string();
1025 }
1026
1027 let kept_lines = if total_lines > MAX_TOOL_UI_LINES {
1028 MAX_TOOL_UI_LINES - 1
1029 } else {
1030 total_lines
1031 };
1032 let line_budget = MAX_TOOL_UI_BYTES / kept_lines.max(1);
1033 let mut preview = String::new();
1034 let mut first = true;
1035 let mut append = |line: &str| {
1036 if !first {
1037 preview.push('\n');
1038 }
1039 first = false;
1040 preview.push_str(&capped(line, line_budget));
1041 };
1042
1043 if total_lines <= MAX_TOOL_UI_LINES {
1044 output.lines().for_each(&mut append);
1045 return preview;
1046 }
1047
1048 let head_lines = (MAX_TOOL_UI_LINES - 1) / 2;
1049 output.lines().take(head_lines).for_each(&mut append);
1050 append(&format!(
1051 "… +{} lines",
1052 total_lines - (MAX_TOOL_UI_LINES - 1)
1053 ));
1054 let mut tail = output
1055 .lines()
1056 .rev()
1057 .take(MAX_TOOL_UI_LINES - 1 - head_lines)
1058 .collect::<Vec<_>>();
1059 tail.reverse();
1060 tail.into_iter().for_each(append);
1061 preview
1062}
1063
1064pub struct Tools {
1066 tools: Vec<Arc<dyn Tool>>,
1067 names: BTreeSet<String>,
1068}
1069
1070impl Tools {
1071 #[must_use]
1073 pub fn new(tools: Vec<Arc<dyn Tool>>) -> Self {
1074 let mut names = tools
1075 .iter()
1076 .map(|tool| tool.definition().name)
1077 .collect::<BTreeSet<_>>();
1078 names.insert(TOOLS_SEARCH_NAME.into());
1079 Self { tools, names }
1080 }
1081
1082 #[must_use]
1084 pub fn coding() -> Self {
1085 Self::new(vec![
1086 Arc::new(ReadFile),
1087 Arc::new(WriteFile),
1088 Arc::new(ApplyPatch),
1089 Arc::new(Bash),
1090 Arc::new(StartCommand),
1091 Arc::new(PollCommand),
1092 Arc::new(StopCommand),
1093 ])
1094 }
1095
1096 fn section(&self) -> PromptSection {
1097 PromptSection::new(text::PROMPT_MAIN)
1098 }
1099}
1100
1101impl Middleware for Tools {
1102 fn name(&self) -> &'static str {
1103 MANIFEST.id
1104 }
1105
1106 fn register(&self, catalog: &mut Catalog, _runtime: &super::RuntimeContext) -> Result<()> {
1107 for tool in &self.tools {
1108 catalog.register(Arc::clone(tool))?;
1109 }
1110 Ok(())
1111 }
1112
1113 fn prompt_section(&self, _runtime: &super::RuntimeContext) -> Result<Option<PromptSection>> {
1114 Ok(Some(self.section()))
1115 }
1116
1117 fn frontend(&self) -> FrontendContribution {
1118 FrontendContribution {
1119 capability: self.name().into(),
1120 ..FrontendContribution::default()
1121 }
1122 }
1123
1124 fn render(&self, event: &EventMsg, _session_id: &str) -> Option<FrontendBlock> {
1125 if let EventMsg::ToolLoad(load) = event {
1126 return Some(FrontendBlock {
1127 id: Some(format!("{}/{}/load", load.turn_id, load.load_id)),
1128 group: None,
1129 update: FrontendBlockUpdate::Replace,
1130 state: FrontendBlockState::Complete,
1131 role: FrontendBlockRole::Tool,
1132 title: text::RENDER_LOAD.into(),
1133 text: load.tools.join("\n"),
1134 symbol: None,
1135 files: Vec::new(),
1136 format: FrontendBlockFormat::PlainText,
1137 tone: FrontendTone::Success,
1138 });
1139 }
1140 let mut block = render_tool_event(event, |name| self.names.contains(name), tool_heading)?;
1141 match event {
1142 EventMsg::ToolCallBegin(call) if call.name == "read_file" => {
1143 block.group = Some(format!("read:{}", call.turn_id));
1144 }
1145 EventMsg::ToolCallEnd(result) if result.name == "read_file" => {
1146 block.group = Some(format!("read:{}", result.turn_id));
1147 }
1148 EventMsg::ToolCallEnd(result)
1149 if !result.is_error
1150 && result.name == "apply_patch"
1151 && Patch::from_str(&result.output).is_ok() =>
1152 {
1153 block.update = FrontendBlockUpdate::Replace;
1154 block.title = tool_heading(&result.name, &Value::Null).title;
1155 block.text = result.output.clone();
1156 block.format = FrontendBlockFormat::UnifiedDiff;
1157 }
1158 _ => {}
1159 }
1160 Some(block)
1161 }
1162}
1163
1164pub(crate) fn render_tool_event(
1165 event: &EventMsg,
1166 owns: impl Fn(&str) -> bool,
1167 heading: impl Fn(&str, &Value) -> ToolHeading,
1168) -> Option<FrontendBlock> {
1169 match event {
1170 EventMsg::ToolCallBegin(call) if owns(&call.name) => {
1171 let heading = heading(&call.name, &call.arguments);
1172 Some(FrontendBlock {
1173 id: Some(format!("{}/{}", call.turn_id, call.call_id)),
1174 group: None,
1175 update: FrontendBlockUpdate::Replace,
1176 state: FrontendBlockState::Pending,
1177 role: FrontendBlockRole::Tool,
1178 title: heading.title,
1179 text: heading.detail,
1180 symbol: None,
1181 files: Vec::new(),
1182 format: FrontendBlockFormat::PlainText,
1183 tone: FrontendTone::Neutral,
1184 })
1185 }
1186 EventMsg::ToolCallEnd(result) if owns(&result.name) => {
1187 let output = compact_output(&result.output);
1188 Some(FrontendBlock {
1189 id: Some(format!("{}/{}", result.turn_id, result.call_id)),
1190 group: None,
1191 update: FrontendBlockUpdate::Append,
1192 state: FrontendBlockState::Complete,
1193 role: FrontendBlockRole::Tool,
1194 title: tool_heading(&result.name, &Value::Null).title,
1195 text: output,
1196 symbol: None,
1197 files: Vec::new(),
1198 format: FrontendBlockFormat::PlainText,
1199 tone: if result.is_error {
1200 FrontendTone::Error
1201 } else {
1202 FrontendTone::Success
1203 },
1204 })
1205 }
1206 _ => None,
1207 }
1208}
1209
1210pub(crate) struct ToolHeading {
1211 pub(crate) title: String,
1212 pub(crate) detail: String,
1213}
1214
1215impl From<&str> for ToolHeading {
1216 fn from(title: &str) -> Self {
1217 Self {
1218 title: title.into(),
1219 detail: String::new(),
1220 }
1221 }
1222}
1223
1224impl From<String> for ToolHeading {
1225 fn from(title: String) -> Self {
1226 Self {
1227 title,
1228 detail: String::new(),
1229 }
1230 }
1231}
1232
1233fn tool_heading(name: &str, arguments: &Value) -> ToolHeading {
1234 if name == "apply_patch" {
1235 let detail = arguments
1236 .get("patch")
1237 .and_then(Value::as_str)
1238 .and_then(|patch| {
1239 patch
1240 .lines()
1241 .find_map(|line| line.strip_prefix("*** Update File: "))
1242 })
1243 .unwrap_or_default()
1244 .into();
1245 return ToolHeading {
1246 title: text::RENDER_APPLY_PATCH.into(),
1247 detail,
1248 };
1249 }
1250 let (label, detail) = match name {
1251 "read_file" => (text::RENDER_READ_FILE, "path"),
1252 "write_file" => (text::RENDER_WRITE_FILE, "path"),
1253 "bash" => (text::RENDER_BASH, "command"),
1254 "start_command" => (text::RENDER_START_COMMAND, "command"),
1255 "poll_command" => (text::RENDER_POLL_COMMAND, "command_id"),
1256 "stop_command" => (text::RENDER_STOP_COMMAND, "command_id"),
1257 _ => {
1258 return ToolHeading {
1259 title: name.into(),
1260 detail: preview_json(arguments),
1261 };
1262 }
1263 };
1264 labeled_tool_heading(label, detail, arguments)
1265}
1266
1267pub(crate) fn labeled_tool_heading(label: &str, detail: &str, arguments: &Value) -> ToolHeading {
1268 ToolHeading {
1269 title: label.into(),
1270 detail: arguments
1271 .get(detail)
1272 .and_then(Value::as_str)
1273 .filter(|value| !value.is_empty())
1274 .unwrap_or_default()
1275 .into(),
1276 }
1277}
1278
1279#[cfg(test)]
1280#[path = "tools_tests.rs"]
1281mod tests;