Skip to main content

mobius/middleware/
tools.rs

1//! Tool registry, dispatch, and minimal filesystem tools.
2
3use 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
69/// Configuration and presentation metadata for workspace tools.
70pub 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/// Whether a tool can overlap other calls in its model-produced batch.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ExecutionMode {
82    Parallel,
83    Exclusive,
84}
85
86/// Whether a tool requires sandbox mutation approval.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum ApprovalRequirement {
89    Never,
90    Always,
91}
92
93/// Whether a tool is initially visible to the model.
94#[derive(Debug, Clone, Copy, PartialEq, Eq)]
95pub enum ToolExposure {
96    /// Included in every model request.
97    Direct,
98    /// Discoverable through `tools_search` and callable only after materialization.
99    Deferred,
100    /// Registered for internal ownership but unavailable to the model.
101    Hidden,
102}
103
104/// Dependencies available only to terminal tool handlers.
105pub struct ToolContext {
106    pub sandbox: Arc<Sandbox>,
107    pub permissions: ToolPermissions,
108    pub turn_id: String,
109}
110
111/// External identity used when a tool participates in extension hooks.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113pub struct HookIdentity {
114    /// Tool name exposed to hook matchers and payloads.
115    pub name: &'static str,
116    /// Matcher subjects checked in declaration order.
117    pub subjects: &'static [&'static str],
118}
119
120/// A named tool Adapter registered by middleware.
121pub trait Tool: Send + Sync {
122    /// Returns the provider-facing tool schema.
123    fn definition(&self) -> ToolDefinition;
124
125    /// Declares how the tool is exposed to the model.
126    fn exposure(&self) -> ToolExposure {
127        ToolExposure::Deferred
128    }
129
130    /// Declares whether calls may overlap.
131    fn execution_mode(&self) -> ExecutionMode {
132        ExecutionMode::Exclusive
133    }
134
135    /// Declares whether this tool requires sandbox mutation approval.
136    fn approval(&self) -> ApprovalRequirement {
137        ApprovalRequirement::Never
138    }
139
140    /// Allows accepted model input to cancel this tool while it is waiting.
141    fn cancel_on_input(&self) -> bool {
142        false
143    }
144
145    /// Declares an external hook alias and matcher subjects.
146    fn hook_identity(&self) -> Option<HookIdentity> {
147        None
148    }
149
150    /// Maps provider-facing arguments into the extension hook payload.
151    fn hook_input(&self, arguments: &Value) -> Value {
152        arguments.clone()
153    }
154
155    /// Maps a hook rewrite back into provider-facing arguments.
156    fn rewrite_hook_input(&self, input: Value) -> Result<Value> {
157        object_hook_input(input)
158    }
159
160    /// Executes one validated provider call.
161    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/// The validated tool registry built during agent creation.
188#[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/// One catalog snapshot resolved for a model boundary.
199#[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    /// Registers one tool and rejects invalid definitions or duplicate names.
251    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    /// Freezes the registry and installs `tools_search` when deferred tools exist.
275    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    /// Returns all registered definitions in stable name order.
328    #[must_use]
329    pub fn registered_definitions(&self) -> Arc<[ToolDefinition]> {
330        Arc::clone(&self.registered_definitions)
331    }
332
333    /// Returns definitions included in every model request in stable name order.
334    #[must_use]
335    pub fn direct_definitions(&self) -> Arc<[ToolDefinition]> {
336        Arc::clone(&self.direct_definitions)
337    }
338
339    /// Returns discoverable definitions in stable name order.
340    #[must_use]
341    pub fn deferred_definitions(&self) -> Arc<[ToolDefinition]> {
342        Arc::clone(&self.deferred_definitions)
343    }
344
345    /// Returns the stable schema and exposure fingerprint of this finalized catalog.
346    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    /// Searches currently deferred tools using BM25 relevance ranking.
430    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        // ponytail: rebuild the tiny index per query; cache it if deferred catalogs become large.
457        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    /// Validates a model-returned call against this catalog and its step materialization.
473    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    /// Returns whether the named tool requires approval.
519    #[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/// A tool call proven callable for one model step.
690#[derive(Debug, Clone, PartialEq)]
691pub struct BoundToolCall {
692    call: ToolCall,
693    materialized: bool,
694    search_scope: Option<BTreeSet<String>>,
695}
696
697impl BoundToolCall {
698    /// Returns the original provider call for hooks, approvals, and events.
699    #[must_use]
700    pub fn as_call(&self) -> &ToolCall {
701        &self.call
702    }
703
704    /// Returns the validated provider call.
705    #[must_use]
706    pub fn into_call(self) -> ToolCall {
707        self.call
708    }
709}
710
711/// Returns the core discovery tool schema.
712#[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/// The result returned to the model for one tool call.
761#[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
790/// Executes maximal runs of parallel-safe calls concurrently.
791/// Exclusive calls form barriers and execute alone.
792pub(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            // ModelOutput validation bounds every batch to 128 calls.
808            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
1064/// Middleware that contributes an explicit list of tools.
1065pub struct Tools {
1066    tools: Vec<Arc<dyn Tool>>,
1067    names: BTreeSet<String>,
1068}
1069
1070impl Tools {
1071    /// Creates a tool middleware from explicit handlers.
1072    #[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    /// Creates the default file, foreground command, and background command tools.
1083    #[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;