Skip to main content

cookie_agent_engine/
tool_api.rs

1use std::{path::PathBuf, sync::Arc};
2
3use async_trait::async_trait;
4use cookie_agent_protocol::{
5    AdaptorId, AgentId, ModelCapabilities, ModelKey, PersistedToolResult as ToolResult,
6    PreparedOperationIdentity, RunId, SessionId, Sha256Digest, ToolAttachment, ToolCallId,
7    ToolCallPresentation,
8};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use thiserror::Error;
12use tokio::sync::mpsc;
13use tokio_util::sync::CancellationToken;
14
15use crate::{
16    events::OutputHub,
17    runtime::tool_execution::validate_attachment,
18    runtime::{ArtifactRouter, OutputCapture, ToolCallFailureCode},
19};
20
21#[derive(Clone, Debug, Deserialize, Serialize)]
22pub struct SessionToolContext {
23    pub session: SessionId,
24    #[serde(skip)]
25    prompt_delegate_targets: Option<Vec<(AgentId, String)>>,
26}
27
28impl SessionToolContext {
29    #[must_use]
30    pub const fn new(session: SessionId) -> Self {
31        Self {
32            session,
33            prompt_delegate_targets: None,
34        }
35    }
36
37    pub(crate) fn for_prompt_composition(
38        session: SessionId,
39        delegate_targets: Vec<(AgentId, String)>,
40    ) -> Self {
41        Self {
42            session,
43            prompt_delegate_targets: Some(delegate_targets),
44        }
45    }
46
47    /// Delegate targets frozen for the run currently composing its prompt.
48    pub fn prompt_delegate_targets(
49        &self,
50    ) -> Option<impl ExactSizeIterator<Item = (&AgentId, &str)>> {
51        self.prompt_delegate_targets.as_ref().map(|targets| {
52            targets
53                .iter()
54                .map(|(id, description)| (id, description.as_str()))
55        })
56    }
57}
58
59/// One labeled system-prompt section contributed by a tool provider.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct PromptSection {
62    /// Short human-meaningful title used to identify validation failures.
63    pub title: String,
64    /// Markdown-like body rendered inside the provider provenance wrapper.
65    pub body: String,
66}
67#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
68pub struct ToolSpec {
69    pub name: String,
70    pub permission_name: String,
71    pub description: String,
72    pub parameters: Value,
73    #[serde(default)]
74    pub concurrency: ToolConcurrency,
75    #[serde(default)]
76    pub result_truncation: ToolResultTruncationPolicy,
77    #[serde(default)]
78    pub output: cookie_agent_protocol::ToolOutputDeclaration,
79}
80
81/// Completion supplies output once, or explicitly finalizes already accepted deltas.
82#[derive(Clone, Debug)]
83pub struct ToolCompletion {
84    pub output: cookie_agent_protocol::ToolCompletionOutput,
85    pub result: ToolResult,
86    pub failed: bool,
87}
88
89impl ToolCompletion {
90    #[cfg(any(test, feature = "test-support"))]
91    pub fn into_result_for_test(self) -> Result<ToolResult, ToolError> {
92        let mut result = self.result;
93        match self.output {
94            cookie_agent_protocol::ToolCompletionOutput::Single { text } => result.output = text,
95            _ => {
96                return Err(ToolError::execution(
97                    "streamed tests must use runtime capture",
98                ));
99            }
100        }
101        Ok(result)
102    }
103
104    pub fn single(mut result: ToolResult) -> Self {
105        let text = std::mem::take(&mut result.output);
106        if result.display.is_none() {
107            result.display = Some(bounded_tool_display(&text));
108        }
109        Self {
110            output: cookie_agent_protocol::ToolCompletionOutput::Single { text },
111            result,
112            failed: false,
113        }
114    }
115
116    pub fn streamed(mut result: ToolResult) -> Self {
117        if result.display.is_none() {
118            result.display = Some(String::new());
119        }
120        Self {
121            output: cookie_agent_protocol::ToolCompletionOutput::Streamed,
122            result,
123            failed: false,
124        }
125    }
126}
127
128pub(crate) fn bounded_tool_display(text: &str) -> String {
129    sanitize_tool_display(text, cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES)
130}
131
132pub(crate) fn sanitize_tool_display(text: &str, maximum: usize) -> String {
133    let mut display = String::new();
134    for character in text.chars() {
135        let character = if character.is_control() && !matches!(character, '\n' | '\t') {
136            ' '
137        } else {
138            character
139        };
140        if display.len() + character.len_utf8() > maximum {
141            break;
142        }
143        display.push(character);
144    }
145    display
146}
147
148/// Declares whether calls to a tool may overlap with sibling calls from one model turn.
149#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum ToolConcurrency {
152    #[default]
153    Exclusive,
154    Parallel,
155}
156
157#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
158#[serde(rename_all = "snake_case")]
159pub enum ToolResultTruncationPolicy {
160    #[default]
161    Bounded,
162    OptOut,
163}
164
165pub(crate) const UNSCOPED_PERMISSION_RESOURCE_DISPLAY: &str = "<permission-name-only>";
166#[derive(Clone, Debug, Deserialize, Serialize)]
167pub struct ToolCall {
168    pub id: ToolCallId,
169    pub name: String,
170    pub arguments: Value,
171}
172#[derive(Clone, Debug, Deserialize, Serialize)]
173pub struct ToolProgress {
174    pub tool_call_id: ToolCallId,
175    pub message: String,
176    pub display: Option<String>,
177    #[serde(default)]
178    pub output: Vec<cookie_agent_protocol::ToolOutputChunk>,
179}
180
181#[derive(Clone, Debug)]
182pub struct ProgressSink {
183    sender: mpsc::Sender<ToolProgress>,
184    output: OutputHub,
185    capture: Option<OutputCapture>,
186    display_used: Arc<std::sync::Mutex<usize>>,
187}
188impl ProgressSink {
189    #[cfg(feature = "test-support")]
190    pub async fn for_test(
191        sender: mpsc::Sender<ToolProgress>,
192        output: OutputHub,
193        directory: PathBuf,
194        declaration: cookie_agent_protocol::ToolOutputDeclaration,
195    ) -> Result<Self, ToolError> {
196        let router = ArtifactRouter::open_flat(directory)
197            .map_err(|e| ToolError::execution(e.to_string()))?;
198        let capture =
199            OutputCapture::new(router, SessionId::new_v7(), declaration, 2_000, 16 * 1024).await?;
200        Ok(Self::with_capture(sender, output, capture))
201    }
202
203    #[must_use]
204    pub fn new(sender: mpsc::Sender<ToolProgress>, output: OutputHub) -> Self {
205        Self {
206            sender,
207            output,
208            capture: None,
209            display_used: Arc::new(std::sync::Mutex::new(0)),
210        }
211    }
212    pub(crate) fn with_capture(
213        sender: mpsc::Sender<ToolProgress>,
214        output: OutputHub,
215        capture: OutputCapture,
216    ) -> Self {
217        Self {
218            sender,
219            output,
220            capture: Some(capture),
221            display_used: Arc::new(std::sync::Mutex::new(0)),
222        }
223    }
224    pub async fn send(&self, mut progress: ToolProgress) -> Result<(), ToolError> {
225        if !progress.output.is_empty() && self.capture.is_none() {
226            return Err(ToolError::execution("output capture is unavailable"));
227        }
228        if progress.message.len() > cookie_agent_protocol::SafeDisplayText::MAX_BYTES
229            || progress.display.as_ref().is_some_and(|display| {
230                display.len() > cookie_agent_protocol::SafeDisplayText::MAX_BYTES
231            })
232        {
233            return Err(ToolError::resource_limit(
234                "tool display delta exceeds the display event bound",
235            ));
236        }
237        if progress.output.len() > cookie_agent_protocol::MAX_TOOL_STREAMS
238            || progress.output.iter().fold(0_usize, |total, chunk| {
239                total.saturating_add(chunk.text.len())
240            }) > cookie_agent_protocol::MAX_TOOL_DELTA_BYTES
241        {
242            return Err(ToolError::resource_limit(
243                "tool output delta exceeds 64 KiB or 8 chunks",
244            ));
245        }
246        for chunk in &progress.output {
247            if let Some(name) = &chunk.stream {
248                cookie_agent_protocol::validate_tool_stream_name(name)
249                    .map_err(ToolError::execution)?;
250            }
251        }
252        {
253            let mut used = self.display_used.lock().unwrap_or_else(|p| p.into_inner());
254            let remaining = cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES.saturating_sub(*used);
255            progress.message = sanitize_tool_display(&progress.message, remaining);
256            *used += progress.message.len();
257            progress.display = progress
258                .display
259                .as_deref()
260                .map(|text| {
261                    sanitize_tool_display(
262                        text,
263                        cookie_agent_protocol::MAX_TOOL_DISPLAY_BYTES.saturating_sub(*used),
264                    )
265                })
266                .filter(|text| !text.is_empty());
267            *used += progress.display.as_ref().map_or(0, String::len);
268        }
269        let permit = if progress.message.is_empty() && progress.display.is_none() {
270            None
271        } else {
272            Some(
273                self.sender
274                    .clone()
275                    .reserve_owned()
276                    .await
277                    .map_err(|_| ToolError::ProgressSinkClosed)?,
278            )
279        };
280        if let Some(capture) = &self.capture {
281            return capture.emit(progress, self.output.clone(), permit).await;
282        }
283        if let Some(permit) = permit {
284            permit.send(progress);
285        }
286        Ok(())
287    }
288}
289
290#[derive(Debug)]
291pub struct ToolStdin {
292    receiver: mpsc::Receiver<StdinWrite>,
293}
294impl ToolStdin {
295    /// Builds the sender/receiver pair used by interactive tool tests and by
296    /// the engine's per-call stdin registry.
297    #[must_use]
298    pub fn channel(capacity: usize) -> (mpsc::Sender<StdinWrite>, Self) {
299        let (sender, receiver) = mpsc::channel(capacity);
300        (sender, Self { receiver })
301    }
302
303    #[must_use]
304    pub fn from_receiver(receiver: mpsc::Receiver<StdinWrite>) -> Self {
305        Self { receiver }
306    }
307
308    pub async fn recv(&mut self) -> Option<StdinWrite> {
309        self.receiver.recv().await
310    }
311}
312#[derive(Clone, Debug)]
313pub struct StdinWrite {
314    pub data: Vec<u8>,
315    pub eof: bool,
316}
317
318/// Immutable harness context captured for one tool preparation/execution batch.
319///
320/// This is tool-facing but harness-private metadata. Tool providers must not forward it to
321/// external systems, including MCP wrappers. Compaction rehydration uses the context of the owner
322/// policy and model binding that triggered the checkpoint.
323#[derive(Debug)]
324pub struct TurnAgentContext {
325    /// Agent that owns the tool call.
326    pub agent: AgentId,
327    /// Exact model selected for this turn.
328    pub model: ModelKey,
329    /// Frozen wire adapter family used to deliver tool results.
330    pub adapter: AdaptorId,
331    /// Exact Oven adapter family, preserving compatible-family distinctions.
332    pub adapter_family: cookie_agent_models::adapters::OvenAdapterFamily,
333    /// Public capabilities of the exact model binding that produced the tool call.
334    pub capabilities: ModelCapabilities,
335}
336
337#[derive(Clone, Debug)]
338pub struct ToolPreparationContext {
339    pub session: SessionId,
340    pub run: RunId,
341    pub cwd: PathBuf,
342    pub workspace_root: PathBuf,
343    /// Static agent/model context shared with execution for this batch.
344    pub turn_context: Arc<TurnAgentContext>,
345}
346
347#[derive(Debug)]
348pub struct ToolExecutionContext {
349    pub session: SessionId,
350    pub run: RunId,
351    pub progress: ProgressSink,
352    pub cancellation: CancellationToken,
353    pub stdin: Option<ToolStdin>,
354    /// Static agent/model context shared with preparation for this batch.
355    pub turn_context: Arc<TurnAgentContext>,
356    pub(crate) artifacts: Arc<ArtifactRouter>,
357}
358
359impl ToolExecutionContext {
360    pub async fn read_artifact(
361        &self,
362        path: &str,
363        offset: u64,
364        limit: u64,
365    ) -> Result<crate::ArtifactReadPage, ToolError> {
366        crate::runtime::read_artifact_async(self.artifacts.clone(), path, offset, limit).await
367    }
368
369    #[cfg(feature = "test-support")]
370    pub fn for_test(
371        artifact_directory: impl Into<PathBuf>,
372        turn_context: Arc<TurnAgentContext>,
373    ) -> Result<Self, ToolError> {
374        let call_id = ToolCallId::new_v7();
375        let (progress, _receiver) = mpsc::channel(1);
376        Ok(Self {
377            session: SessionId::new_v7(),
378            run: RunId::new_v7(),
379            progress: ProgressSink::new(progress, OutputHub::new(call_id, 1024)),
380            cancellation: CancellationToken::new(),
381            stdin: None,
382            turn_context,
383            artifacts: ArtifactRouter::open_flat(artifact_directory.into())
384                .map_err(|error| ToolError::execution(error.to_string()))?,
385        })
386    }
387
388    pub fn retain_attachment(
389        &self,
390        mime_type: impl Into<String>,
391        filename: Option<String>,
392        bytes: &[u8],
393    ) -> Result<ToolAttachment, ToolError> {
394        let mime_type = mime_type.into();
395        let path = filename.as_deref().map_or_else(PathBuf::new, PathBuf::from);
396        validate_attachment(&mime_type, &path, bytes)?;
397        self.retain_validated_attachment(mime_type, filename, bytes)
398    }
399
400    pub fn retain_validated_attachment(
401        &self,
402        mime_type: impl Into<String>,
403        filename: Option<String>,
404        bytes: &[u8],
405    ) -> Result<ToolAttachment, ToolError> {
406        let mime_type = mime_type.into();
407        let (reference, sha256) = self
408            .artifacts
409            .retain(self.session, bytes)
410            .map_err(|error| ToolError::execution(error.to_string()))?;
411        Ok(ToolAttachment {
412            mime_type: cookie_agent_protocol::MimeType::new(mime_type)
413                .map_err(|error| ToolError::execution(error.to_string()))?,
414            filename,
415            byte_length: bytes.len() as u64,
416            sha256: Sha256Digest::new(sha256)
417                .map_err(|error| ToolError::execution(error.to_string()))?,
418            reference,
419        })
420    }
421}
422#[derive(Debug, Error)]
423pub enum ToolError {
424    #[error("tool progress sink closed")]
425    ProgressSinkClosed,
426    #[error("tool failed: {0}")]
427    Failed(String),
428    #[error("prepared operation changed: {0}")]
429    OperationChanged(String),
430    #[error("unsupported prepared-operation security: {0}")]
431    UnsupportedSecurity(String),
432    #[error("prepared operation is unsupported on this platform: {0}")]
433    UnsupportedPlatform(String),
434    #[error("prepared capability resource limit exceeded: {0}")]
435    ResourceLimit(String),
436    #[error("invalid URL: {0}")]
437    InvalidUrl(String),
438    #[error("permission denied: {0}")]
439    PermissionDenied(String),
440    #[error("redirect error: {0}")]
441    RedirectError(String),
442    #[error("request timed out: {0}")]
443    Timeout(String),
444    #[error("transport error: {0}")]
445    TransportError(String),
446    #[error("unsupported content type: {0}")]
447    UnsupportedContentType(String),
448}
449
450impl ToolError {
451    #[must_use]
452    pub fn operation_changed(message: impl Into<String>) -> Self {
453        Self::OperationChanged(message.into())
454    }
455
456    #[must_use]
457    pub fn unsupported_security(message: impl Into<String>) -> Self {
458        Self::UnsupportedSecurity(message.into())
459    }
460
461    #[must_use]
462    pub fn unsupported_platform(message: impl Into<String>) -> Self {
463        Self::UnsupportedPlatform(message.into())
464    }
465
466    #[must_use]
467    pub fn resource_limit(message: impl Into<String>) -> Self {
468        Self::ResourceLimit(message.into())
469    }
470
471    #[must_use]
472    pub fn execution(message: impl Into<String>) -> Self {
473        Self::Failed(cookie_agent_protocol::diagnostics::detail(&message.into()).to_string())
474    }
475
476    #[must_use]
477    pub(crate) const fn code(&self) -> ToolCallFailureCode {
478        match self {
479            Self::ProgressSinkClosed | Self::Failed(_) => ToolCallFailureCode::ExecutionFailed,
480            Self::OperationChanged(_) => ToolCallFailureCode::OperationChanged,
481            Self::UnsupportedSecurity(_) | Self::ResourceLimit(_) => {
482                ToolCallFailureCode::ExecutionFailed
483            }
484            Self::UnsupportedPlatform(_) => ToolCallFailureCode::UnsupportedPlatform,
485            Self::InvalidUrl(_) => ToolCallFailureCode::InvalidUrl,
486            Self::PermissionDenied(_) => ToolCallFailureCode::PermissionDenied,
487            Self::RedirectError(_) => ToolCallFailureCode::RedirectError,
488            Self::Timeout(_) => ToolCallFailureCode::Timeout,
489            Self::TransportError(_) => ToolCallFailureCode::TransportError,
490            Self::UnsupportedContentType(_) => ToolCallFailureCode::UnsupportedContentType,
491        }
492    }
493
494    #[must_use]
495    pub fn message(&self) -> String {
496        match self {
497            Self::ProgressSinkClosed => "tool progress sink closed".into(),
498            Self::Failed(message)
499            | Self::OperationChanged(message)
500            | Self::UnsupportedSecurity(message)
501            | Self::UnsupportedPlatform(message)
502            | Self::InvalidUrl(message)
503            | Self::PermissionDenied(message)
504            | Self::RedirectError(message)
505            | Self::Timeout(message)
506            | Self::TransportError(message)
507            | Self::UnsupportedContentType(message)
508            | Self::ResourceLimit(message) => message.clone(),
509        }
510    }
511}
512
513#[derive(Clone, Debug, Eq, Hash, PartialEq)]
514pub struct PreparedSerializationKey(Vec<u8>);
515
516impl PreparedSerializationKey {
517    #[must_use]
518    pub fn new(bytes: impl Into<Vec<u8>>) -> Self {
519        Self(bytes.into())
520    }
521}
522
523#[async_trait]
524pub trait PreparedExecutor: Send + Sync {
525    async fn revalidate(&self) -> Result<(), ToolError>;
526
527    /// Revalidation performed under the per-target serialization lock immediately before execution.
528    /// The default preserves the ordinary validation behavior.
529    async fn revalidate_for_execution(&self) -> Result<(), ToolError> {
530        self.revalidate().await
531    }
532
533    async fn execute(
534        self: Box<Self>,
535        context: ToolExecutionContext,
536    ) -> Result<crate::ToolCompletion, ToolError>;
537}
538
539pub struct PreparedTool {
540    pub(crate) operation: PreparedOperationIdentity,
541    pub(crate) policy_labels: Vec<Option<String>>,
542    pub(crate) normalized_arguments: serde_json::Value,
543    pub(crate) serialization_key: Option<PreparedSerializationKey>,
544    pub(crate) executor: PreparedExecutorCell,
545}
546
547pub(crate) type PreparedExecutorCell = Arc<tokio::sync::Mutex<Option<Box<dyn PreparedExecutor>>>>;
548
549impl PreparedTool {
550    pub fn new(
551        operation: PreparedOperationIdentity,
552        normalized_arguments: serde_json::Value,
553        serialization_key: Option<PreparedSerializationKey>,
554        executor: Box<dyn PreparedExecutor>,
555    ) -> Result<Self, ToolError> {
556        if operation.resources().is_empty() {
557            return Err(ToolError::execution(
558                "prepared tool requires at least one permission resource",
559            ));
560        }
561        if normalized_arguments.is_null() {
562            return Err(ToolError::execution(
563                "prepared normalized arguments must not be null",
564            ));
565        }
566        let policy_labels = operation
567            .resources()
568            .iter()
569            .map(|resource| Some(resource.canonical.as_str().to_owned()))
570            .collect();
571        Ok(Self {
572            operation,
573            policy_labels,
574            normalized_arguments,
575            serialization_key,
576            executor: Arc::new(tokio::sync::Mutex::new(Some(executor))),
577        })
578    }
579
580    #[must_use]
581    pub const fn operation(&self) -> &PreparedOperationIdentity {
582        &self.operation
583    }
584
585    pub fn with_policy_labels(mut self, labels: Vec<String>) -> Result<Self, ToolError> {
586        if labels.is_empty() || labels.len() != self.operation.resources().len() {
587            return Err(ToolError::execution(
588                "prepared policy labels must cover every resource",
589            ));
590        }
591        for (resource, label) in self.operation.resources().iter().zip(&labels) {
592            let expected = Sha256Digest::of_bytes(label.as_bytes());
593            if resource
594                .canonical
595                .as_str()
596                .rsplit_once(':')
597                .is_none_or(|(_, digest)| digest != expected.as_str())
598            {
599                return Err(ToolError::execution(
600                    "prepared policy label does not match its immutable resource identity",
601                ));
602            }
603        }
604        self.policy_labels = labels.into_iter().map(Some).collect();
605        Ok(self)
606    }
607
608    pub fn with_permission_resource(mut self, resource: Option<String>) -> Result<Self, ToolError> {
609        if resource.as_ref().is_some_and(String::is_empty) {
610            return Err(ToolError::execution(
611                "permission resource must not be empty",
612            ));
613        }
614        self.policy_labels.fill(resource);
615        Ok(self)
616    }
617
618    #[must_use]
619    pub const fn normalized_arguments(&self) -> &serde_json::Value {
620        &self.normalized_arguments
621    }
622
623    #[must_use]
624    pub fn policy_labels(&self) -> &[Option<String>] {
625        &self.policy_labels
626    }
627
628    #[cfg(feature = "test-support")]
629    pub async fn execute_for_test(
630        self,
631        context: ToolExecutionContext,
632    ) -> Result<ToolResult, ToolError> {
633        let executor = self
634            .executor
635            .lock()
636            .await
637            .take()
638            .ok_or_else(|| ToolError::execution("prepared executor was already consumed"))?;
639        executor.execute(context).await?.into_result_for_test()
640    }
641
642    #[cfg(feature = "test-support")]
643    pub async fn revalidate_for_execution_for_test(&self) -> Result<(), ToolError> {
644        let executor = self.executor.lock().await;
645        executor
646            .as_ref()
647            .ok_or_else(|| ToolError::execution("prepared executor was already consumed"))?
648            .revalidate_for_execution()
649            .await
650    }
651}
652
653#[async_trait]
654pub trait ToolProvider: Send + Sync {
655    /// Stable engine-facing identity used for system-prompt provenance labels.
656    fn provider_id(&self) -> &'static str;
657
658    /// Sections resolved once at run admission and frozen into the agent snapshot.
659    fn prompt_sections(&self, _ctx: &SessionToolContext) -> Result<Vec<PromptSection>, ToolError> {
660        Ok(Vec::new())
661    }
662
663    fn tools_for_session(&self, ctx: &SessionToolContext) -> Result<Vec<ToolSpec>, ToolError>;
664    /// Claims a currently undiscovered dynamic tool, allowing preparation to make it available.
665    fn permission_for_unlisted_tool(
666        &self,
667        _tool_name: &str,
668    ) -> Result<Option<&'static str>, ToolError> {
669        Ok(None)
670    }
671    fn get_permission_name(tool_name: &str) -> Result<&'static str, ToolError>
672    where
673        Self: Sized;
674    fn get_permission_resource(
675        &self,
676        tool_name: &str,
677        arguments: &Value,
678    ) -> Result<(&'static str, Option<String>), ToolError>;
679    fn get_display_argument(&self, name: &str, arguments: &Value) -> Result<String, ToolError>;
680
681    fn presentation(&self, call: &ToolCall) -> ToolCallPresentation {
682        match self.get_display_argument(&call.name, &call.arguments) {
683            Ok(display) => crate::runtime::tool_execution::tool_presentation(&call.name, &display),
684            Err(_) => crate::runtime::tool_execution::tool_title_only(&call.name),
685        }
686    }
687    async fn prepare(
688        &self,
689        ctx: ToolPreparationContext,
690        call: ToolCall,
691    ) -> Result<PreparedTool, ToolError>;
692
693    /// Prepare one turn's parallel call batch. Providers whose calls may target
694    /// the same mutable resource can chain preparation against successive states.
695    async fn prepare_parallel(
696        &self,
697        ctx: ToolPreparationContext,
698        calls: Vec<ToolCall>,
699    ) -> Vec<Result<PreparedTool, ToolError>> {
700        let mut prepared = Vec::with_capacity(calls.len());
701        for call in calls {
702            prepared.push(self.prepare(ctx.clone(), call).await);
703        }
704        prepared
705    }
706}
707
708#[cfg(test)]
709mod tests {
710    use cookie_agent_protocol::{
711        PersistedToolResult as ToolResult, PreparedOperationIdentity, Sha256Digest,
712    };
713
714    use super::{
715        PreparedExecutor, PreparedTool, ToolConcurrency, ToolError, ToolExecutionContext, ToolSpec,
716        async_trait,
717    };
718
719    struct NoopExecutor;
720
721    #[async_trait]
722    impl PreparedExecutor for NoopExecutor {
723        async fn revalidate(&self) -> Result<(), ToolError> {
724            Ok(())
725        }
726
727        async fn execute(
728            self: Box<Self>,
729            _context: ToolExecutionContext,
730        ) -> Result<crate::ToolCompletion, ToolError> {
731            let result: Result<ToolResult, ToolError> =
732                async move { unreachable!("constructor validation test never executes") }.await;
733            result.map(crate::ToolCompletion::single)
734        }
735    }
736
737    fn operation() -> PreparedOperationIdentity {
738        let label = "command:test";
739        PreparedOperationIdentity::new(
740            Sha256Digest::of_bytes(b"arguments"),
741            vec![cookie_agent_protocol::ApprovalCapability {
742                action: cookie_agent_protocol::PermissionAction::Bash,
743                operation: cookie_agent_protocol::PreparedCapabilityOperation::new("bash:execute")
744                    .expect("capability operation"),
745            }],
746            vec![cookie_agent_protocol::PreparedApprovalResource {
747                capability: cookie_agent_protocol::PermissionAction::Bash,
748                canonical: cookie_agent_protocol::PreparedResourceIdentity::new(format!(
749                    "command:{}",
750                    Sha256Digest::of_bytes(label.as_bytes())
751                ))
752                .expect("resource identity"),
753                binding_digest:
754                    cookie_agent_protocol::PreparedResourceDigest::from_canonical_binding_bytes(
755                        label.as_bytes(),
756                    ),
757                binding_lifetime: cookie_agent_protocol::PreparedBindingLifetime::ProcessLocal,
758                boundary: cookie_agent_protocol::ApprovalBoundary::Exact,
759                source: cookie_agent_protocol::ApprovalResourceSource::PrimaryOperation,
760            }],
761            Sha256Digest::of_bytes(b"context"),
762        )
763        .expect("prepared operation")
764    }
765
766    #[test]
767    fn prepared_tool_rejects_null_normalized_arguments() {
768        let error = match PreparedTool::new(
769            operation(),
770            serde_json::Value::Null,
771            None,
772            Box::new(NoopExecutor),
773        ) {
774            Ok(_) => panic!("null normalized arguments must fail"),
775            Err(error) => error,
776        };
777        assert!(error.to_string().contains("must not be null"));
778    }
779
780    #[test]
781    fn tool_concurrency_defaults_to_exclusive_when_omitted() {
782        let spec: ToolSpec = serde_json::from_value(serde_json::json!({
783            "name":"external_tool",
784            "permission_name":"external",
785            "description":"External tool",
786            "parameters":{"type":"object"}
787        }))
788        .expect("tool spec without concurrency");
789        assert_eq!(spec.concurrency, ToolConcurrency::Exclusive);
790    }
791
792    #[test]
793    fn absent_permission_resource_sets_the_loose_policy_marker() {
794        let prepared = PreparedTool::new(
795            operation(),
796            serde_json::json!({}),
797            None,
798            Box::new(NoopExecutor),
799        )
800        .expect("prepared tool")
801        .with_permission_resource(None)
802        .expect("loose permission resource");
803        assert_eq!(prepared.policy_labels(), [None]);
804    }
805}