Skip to main content

llm_tool_runtime/
starter_tools.rs

1use crate::{
2    Tool, ToolApprovalKind, ToolBackendKind, ToolCall, ToolCtx, ToolDescriptor, ToolError,
3    ToolExposureMode, ToolExposurePolicy, ToolIdempotencyClass, ToolOutputMode,
4    ToolReceiptPersistence, ToolResult, ToolSideEffectClass,
5};
6use async_trait::async_trait;
7use serde::{Deserialize, Serialize};
8use serde_json::json;
9use stack_ids::{ArtifactId, ScopeKey};
10use std::sync::Arc;
11
12#[async_trait]
13pub trait MemorySearchPort: Send + Sync {
14    async fn search(
15        &self,
16        query: &str,
17        scope: Option<&ScopeKey>,
18        limit: usize,
19    ) -> Result<serde_json::Value, ToolError>;
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct ArtifactContent {
24    pub artifact_id: ArtifactId,
25    pub content: String,
26}
27
28#[async_trait]
29pub trait ArtifactReader: Send + Sync {
30    async fn read(&self, artifact_id: &ArtifactId) -> Result<ArtifactContent, ToolError>;
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct VerificationJobRequest {
35    pub candidate_id: String,
36    #[serde(default, skip_serializing_if = "Option::is_none")]
37    pub task_id: Option<String>,
38}
39
40#[async_trait]
41pub trait RunVerificationPort: Send + Sync {
42    async fn enqueue_verification(
43        &self,
44        request: VerificationJobRequest,
45        dry_run: bool,
46        execution_permit: &crate::ToolExecutionPermit,
47    ) -> Result<crate::ToolJobHandle, ToolError>;
48}
49
50#[async_trait]
51pub trait ApplyPatchPreviewPort: Send + Sync {
52    async fn preview_patch(
53        &self,
54        target: &str,
55        patch: &str,
56        execution_permit: &crate::ToolExecutionPermit,
57    ) -> Result<serde_json::Value, ToolError>;
58}
59
60#[async_trait]
61pub trait SubmitPatchPort: Send + Sync {
62    async fn submit_patch(
63        &self,
64        target: &str,
65        patch: &str,
66        approval_grant: &crate::ApprovalGrant,
67        execution_permit: &crate::ToolExecutionPermit,
68    ) -> Result<serde_json::Value, ToolError>;
69}
70
71fn scoped_execution_permit<'a>(
72    ctx: &'a ToolCtx,
73    tool_name: &str,
74    target_key: &str,
75) -> Result<&'a crate::ToolExecutionPermit, ToolError> {
76    let permit = ctx.execution_permit.as_ref().ok_or_else(|| {
77        ToolError::new(
78            crate::ToolErrorClass::ApprovalRequired,
79            format!("{tool_name} requires an execution permit"),
80        )
81    })?;
82    let scope = ctx.scope.as_ref().ok_or_else(|| {
83        ToolError::new(
84            crate::ToolErrorClass::Denied,
85            format!("{tool_name} requires a scoped context"),
86        )
87    })?;
88    if permit.scope().namespace() != scope.namespace || permit.scope().target_key() != target_key {
89        return Err(ToolError::new(
90            crate::ToolErrorClass::Denied,
91            format!("{tool_name} execution permit does not cover target {target_key}"),
92        ));
93    }
94    Ok(permit)
95}
96
97pub struct SearchMemoryTool<P> {
98    port: Arc<P>,
99    descriptor: ToolDescriptor,
100}
101
102impl<P> SearchMemoryTool<P> {
103    /// Builds the default search-memory tool descriptor around the supplied port.
104    pub fn new(port: Arc<P>) -> Self {
105        Self {
106            port,
107            descriptor: ToolDescriptor {
108                name: "search_memory".into(),
109                version: "1.0.0".into(),
110                description: Some("Search projected memory within the active scope".into()),
111                backend_kind: ToolBackendKind::LocalFunction,
112                input_schema: json!({
113                    "type": "object",
114                    "required": ["query"],
115                    "properties": {
116                        "query": {"type": "string"},
117                        "limit": {"type": "integer"}
118                    },
119                    "additionalProperties": false
120                }),
121                output_mode: ToolOutputMode::StructuredJson,
122                read_only: true,
123                side_effect_class: ToolSideEffectClass::ReadOnly,
124                idempotency_class: ToolIdempotencyClass::Idempotent,
125                approval_kind: ToolApprovalKind::None,
126                timeout_ms: 10_000,
127                concurrency_key: Some("search_memory".into()),
128                cache_ttl_ms: Some(5_000),
129                exposure_mode: ToolExposureMode::Auto,
130                mcp_surface_kind: crate::McpSurfaceKind::Tool,
131                exposure_policy: ToolExposurePolicy::default(),
132                receipt_persistence: ToolReceiptPersistence::Ephemeral,
133                rollback_contract: None,
134                effect_target: crate::EffectTargetSpec::default(),
135                output_size_limit_bytes: Some(128 * 1024),
136                provider_payload: None,
137            },
138        }
139    }
140}
141
142#[async_trait]
143impl<P> Tool for SearchMemoryTool<P>
144where
145    P: MemorySearchPort + 'static,
146{
147    fn descriptor(&self) -> &ToolDescriptor {
148        &self.descriptor
149    }
150
151    async fn invoke(&self, ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
152        let query = call
153            .arguments
154            .get("query")
155            .and_then(|value| value.as_str())
156            .unwrap_or_default();
157        let limit = call
158            .arguments
159            .get("limit")
160            .and_then(|value| value.as_u64())
161            .unwrap_or(5) as usize;
162        let results = self.port.search(query, ctx.scope.as_ref(), limit).await?;
163        Ok(ToolResult::json(results))
164    }
165}
166
167pub struct ReadArtifactTool<P> {
168    port: Arc<P>,
169    descriptor: ToolDescriptor,
170}
171
172impl<P> ReadArtifactTool<P> {
173    /// Builds the default read-artifact tool descriptor around the supplied port.
174    pub fn new(port: Arc<P>) -> Self {
175        Self {
176            port,
177            descriptor: ToolDescriptor {
178                name: "read_artifact".into(),
179                version: "1.0.0".into(),
180                description: Some("Read the content of a stored artifact".into()),
181                backend_kind: ToolBackendKind::LocalFunction,
182                input_schema: json!({
183                    "type": "object",
184                    "required": ["artifact_id"],
185                    "properties": {
186                        "artifact_id": {"type": "string"}
187                    },
188                    "additionalProperties": false
189                }),
190                output_mode: ToolOutputMode::Text,
191                read_only: true,
192                side_effect_class: ToolSideEffectClass::ReadOnly,
193                idempotency_class: ToolIdempotencyClass::Idempotent,
194                approval_kind: ToolApprovalKind::None,
195                timeout_ms: 10_000,
196                concurrency_key: Some("read_artifact".into()),
197                cache_ttl_ms: Some(5_000),
198                exposure_mode: ToolExposureMode::OptIn,
199                mcp_surface_kind: crate::McpSurfaceKind::Resource,
200                exposure_policy: ToolExposurePolicy::default(),
201                receipt_persistence: ToolReceiptPersistence::Ephemeral,
202                rollback_contract: None,
203                effect_target: crate::EffectTargetSpec::default(),
204                output_size_limit_bytes: Some(256 * 1024),
205                provider_payload: None,
206            },
207        }
208    }
209}
210
211#[async_trait]
212impl<P> Tool for ReadArtifactTool<P>
213where
214    P: ArtifactReader + 'static,
215{
216    fn descriptor(&self) -> &ToolDescriptor {
217        &self.descriptor
218    }
219
220    async fn invoke(&self, _ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
221        let artifact_id = call
222            .arguments
223            .get("artifact_id")
224            .and_then(|value| value.as_str())
225            .unwrap_or_default();
226        let artifact_id_parsed = ArtifactId::try_new(artifact_id).map_err(|e| {
227            ToolError::new(
228                crate::ToolErrorClass::InvalidArguments,
229                format!("invalid artifact_id: {e}"),
230            )
231        })?;
232        let content = self.port.read(&artifact_id_parsed).await?;
233        Ok(ToolResult::text(content.content))
234    }
235}
236
237pub struct RunVerificationTool<P> {
238    port: Arc<P>,
239    descriptor: ToolDescriptor,
240}
241
242impl<P> RunVerificationTool<P> {
243    /// Builds the default run-verification tool descriptor around the supplied port.
244    pub fn new(port: Arc<P>) -> Self {
245        Self {
246            port,
247            descriptor: ToolDescriptor {
248                name: "run_verification".into(),
249                version: "1.0.0".into(),
250                description: Some(
251                    "Enqueue a verification job and return a durable job handle".into(),
252                ),
253                backend_kind: ToolBackendKind::LocalFunction,
254                input_schema: json!({
255                    "type": "object",
256                    "required": ["candidate_id"],
257                    "properties": {
258                        "candidate_id": {"type": "string"},
259                        "task_id": {"type": "string"}
260                    },
261                    "additionalProperties": false
262                }),
263                output_mode: ToolOutputMode::JobHandle,
264                read_only: false,
265                side_effect_class: ToolSideEffectClass::Analysis,
266                idempotency_class: ToolIdempotencyClass::BestEffort,
267                approval_kind: ToolApprovalKind::PolicyRequired,
268                timeout_ms: 30_000,
269                concurrency_key: Some("run_verification".into()),
270                cache_ttl_ms: None,
271                exposure_mode: ToolExposureMode::OptIn,
272                mcp_surface_kind: crate::McpSurfaceKind::Tool,
273                exposure_policy: ToolExposurePolicy::default(),
274                receipt_persistence: ToolReceiptPersistence::ForgeRaw,
275                rollback_contract: None,
276                effect_target: crate::EffectTargetSpec {
277                    aliases: vec!["candidate_id".into()],
278                    compound: Vec::new(),
279                },
280                output_size_limit_bytes: Some(8 * 1024),
281                provider_payload: None,
282            },
283        }
284    }
285}
286
287#[async_trait]
288impl<P> Tool for RunVerificationTool<P>
289where
290    P: RunVerificationPort + 'static,
291{
292    fn descriptor(&self) -> &ToolDescriptor {
293        &self.descriptor
294    }
295
296    async fn invoke(&self, ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
297        let request: VerificationJobRequest = serde_json::from_value(call.arguments.clone())
298            .map_err(|error| ToolError {
299                class: crate::ToolErrorClass::InvalidArguments,
300                message: error.to_string(),
301                retryable: false,
302                details: None,
303            })?;
304        let permit =
305            scoped_execution_permit(ctx, self.descriptor.name.as_str(), &request.candidate_id)?;
306        let handle = self
307            .port
308            .enqueue_verification(request, ctx.dry_run, permit)
309            .await?;
310        Ok(ToolResult::job_handle(handle))
311    }
312}
313
314pub struct ApplyPatchPreviewTool<P> {
315    port: Arc<P>,
316    descriptor: ToolDescriptor,
317}
318
319impl<P> ApplyPatchPreviewTool<P> {
320    /// Builds the default patch-preview tool descriptor around the supplied port.
321    pub fn new(port: Arc<P>) -> Self {
322        Self {
323            port,
324            descriptor: ToolDescriptor {
325                name: "apply_patch_preview".into(),
326                version: "1.0.0".into(),
327                description: Some("Preview a patch application without mutating the target".into()),
328                backend_kind: ToolBackendKind::LocalFunction,
329                input_schema: json!({
330                    "type": "object",
331                    "required": ["target", "patch"],
332                    "properties": {
333                        "target": {"type": "string"},
334                        "patch": {"type": "string"}
335                    },
336                    "additionalProperties": false
337                }),
338                output_mode: ToolOutputMode::StructuredJson,
339                read_only: false,
340                side_effect_class: ToolSideEffectClass::PreviewWrite,
341                idempotency_class: ToolIdempotencyClass::Idempotent,
342                approval_kind: ToolApprovalKind::PolicyRequired,
343                timeout_ms: 10_000,
344                concurrency_key: Some("apply_patch_preview".into()),
345                cache_ttl_ms: None,
346                exposure_mode: ToolExposureMode::OptIn,
347                mcp_surface_kind: crate::McpSurfaceKind::Tool,
348                exposure_policy: ToolExposurePolicy::default(),
349                receipt_persistence: ToolReceiptPersistence::ForgeRaw,
350                rollback_contract: None,
351                effect_target: crate::EffectTargetSpec {
352                    aliases: vec!["target".into()],
353                    compound: Vec::new(),
354                },
355                output_size_limit_bytes: Some(64 * 1024),
356                provider_payload: None,
357            },
358        }
359    }
360}
361
362#[async_trait]
363impl<P> Tool for ApplyPatchPreviewTool<P>
364where
365    P: ApplyPatchPreviewPort + 'static,
366{
367    fn descriptor(&self) -> &ToolDescriptor {
368        &self.descriptor
369    }
370
371    async fn invoke(&self, _ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
372        let target = call
373            .arguments
374            .get("target")
375            .and_then(|value| value.as_str())
376            .unwrap_or_default();
377        let patch = call
378            .arguments
379            .get("patch")
380            .and_then(|value| value.as_str())
381            .unwrap_or_default();
382        let permit = scoped_execution_permit(_ctx, self.descriptor.name.as_str(), target)?;
383        let preview = self.port.preview_patch(target, patch, permit).await?;
384        Ok(ToolResult::json(preview))
385    }
386}
387
388pub struct SubmitPatchTool<P> {
389    port: Arc<P>,
390    descriptor: ToolDescriptor,
391}
392
393impl<P> SubmitPatchTool<P> {
394    /// Builds the default patch-submission tool descriptor around the supplied port.
395    pub fn new(port: Arc<P>) -> Self {
396        Self {
397            port,
398            descriptor: ToolDescriptor {
399                name: "submit_patch".into(),
400                version: "1.0.0".into(),
401                description: Some("Submit a patch to the target with explicit approval".into()),
402                backend_kind: ToolBackendKind::LocalFunction,
403                input_schema: json!({
404                    "type": "object",
405                    "required": ["target", "patch"],
406                    "properties": {
407                        "target": {"type": "string"},
408                        "patch": {"type": "string"}
409                    },
410                    "additionalProperties": false
411                }),
412                output_mode: ToolOutputMode::StructuredJson,
413                read_only: false,
414                side_effect_class: ToolSideEffectClass::Write,
415                idempotency_class: ToolIdempotencyClass::NonIdempotent,
416                approval_kind: ToolApprovalKind::UserRequired,
417                timeout_ms: 10_000,
418                concurrency_key: Some("submit_patch".into()),
419                cache_ttl_ms: None,
420                exposure_mode: ToolExposureMode::Hidden,
421                mcp_surface_kind: crate::McpSurfaceKind::Tool,
422                exposure_policy: ToolExposurePolicy {
423                    exposure_mode: ToolExposureMode::Hidden,
424                    ..Default::default()
425                },
426                receipt_persistence: ToolReceiptPersistence::ForgeRaw,
427                rollback_contract: None,
428                effect_target: crate::EffectTargetSpec {
429                    aliases: vec!["target".into()],
430                    compound: Vec::new(),
431                },
432                output_size_limit_bytes: Some(64 * 1024),
433                provider_payload: None,
434            },
435        }
436    }
437}
438
439#[async_trait]
440impl<P> Tool for SubmitPatchTool<P>
441where
442    P: SubmitPatchPort + 'static,
443{
444    fn descriptor(&self) -> &ToolDescriptor {
445        &self.descriptor
446    }
447
448    async fn invoke(&self, ctx: &ToolCtx, call: &ToolCall) -> Result<ToolResult, ToolError> {
449        let target = call
450            .arguments
451            .get("target")
452            .and_then(|value| value.as_str())
453            .unwrap_or_default();
454        let patch = call
455            .arguments
456            .get("patch")
457            .and_then(|value| value.as_str())
458            .unwrap_or_default();
459        let permit = scoped_execution_permit(ctx, self.descriptor.name.as_str(), target)?;
460        let result = self
461            .port
462            .submit_patch(
463                target,
464                patch,
465                ctx.approval_grant.as_ref().ok_or_else(|| {
466                    ToolError::new(
467                        crate::ToolErrorClass::ApprovalRequired,
468                        "submit_patch requires an approval grant",
469                    )
470                })?,
471                permit,
472            )
473            .await?;
474        Ok(ToolResult::json(result))
475    }
476}
477
478pub struct StarterToolPorts<MS, AR, RV, PP, SP> {
479    pub memory_search: Arc<MS>,
480    pub artifact_reader: Arc<AR>,
481    pub verification: Arc<RV>,
482    pub patch_preview: Arc<PP>,
483    pub patch_submit: Arc<SP>,
484}
485
486#[cfg(test)]
487mod tests {
488    use super::*;
489    use crate::{ToolCall, ToolCtx, ToolOriginKind, ToolRetryOwner};
490    use serde_json::json;
491    use stack_ids::{ArtifactId, AttemptId, TraceCtx, TrialId};
492
493    struct TestMemorySearch;
494
495    #[async_trait]
496    impl MemorySearchPort for TestMemorySearch {
497        async fn search(
498            &self,
499            query: &str,
500            scope: Option<&ScopeKey>,
501            limit: usize,
502        ) -> Result<serde_json::Value, ToolError> {
503            Ok(json!({
504                "query": query,
505                "scope": scope.map(|scope| scope.namespace.clone()),
506                "limit": limit,
507            }))
508        }
509    }
510
511    struct TestArtifactReader;
512
513    #[async_trait]
514    impl ArtifactReader for TestArtifactReader {
515        async fn read(&self, artifact_id: &ArtifactId) -> Result<ArtifactContent, ToolError> {
516            Ok(ArtifactContent {
517                artifact_id: artifact_id.clone(),
518                content: format!("artifact:{}", artifact_id.as_str()),
519            })
520        }
521    }
522
523    struct TestVerificationPort;
524
525    #[async_trait]
526    impl RunVerificationPort for TestVerificationPort {
527        async fn enqueue_verification(
528            &self,
529            request: VerificationJobRequest,
530            dry_run: bool,
531            execution_permit: &crate::ToolExecutionPermit,
532        ) -> Result<crate::ToolJobHandle, ToolError> {
533            Ok(crate::ToolJobHandle {
534                job_id: format!(
535                    "job:{}:{}",
536                    request.candidate_id,
537                    execution_permit.execution_permit_id()
538                ),
539                status: Some(if dry_run { "dry_run" } else { "queued" }.into()),
540            })
541        }
542    }
543
544    struct TestPatchPreviewPort;
545
546    #[async_trait]
547    impl ApplyPatchPreviewPort for TestPatchPreviewPort {
548        async fn preview_patch(
549            &self,
550            target: &str,
551            patch: &str,
552            execution_permit: &crate::ToolExecutionPermit,
553        ) -> Result<serde_json::Value, ToolError> {
554            Ok(json!({
555                "target": target,
556                "patch_len": patch.len(),
557                "execution_permit_id": execution_permit.execution_permit_id(),
558                "preview": true,
559            }))
560        }
561    }
562
563    struct TestSubmitPatchPort;
564
565    #[async_trait]
566    impl SubmitPatchPort for TestSubmitPatchPort {
567        async fn submit_patch(
568            &self,
569            target: &str,
570            patch: &str,
571            approval_grant: &crate::ApprovalGrant,
572            execution_permit: &crate::ToolExecutionPermit,
573        ) -> Result<serde_json::Value, ToolError> {
574            Ok(json!({
575                "target": target,
576                "patch_len": patch.len(),
577                "approval_grant_id": approval_grant.grant_id,
578                "execution_permit_id": execution_permit.execution_permit_id(),
579                "submitted": true,
580            }))
581        }
582    }
583
584    fn execution_permit_for(target_key: &str) -> crate::ToolExecutionPermit {
585        crate::ToolExecutionPermit::new(
586            stack_ids::ExecutionPermitId::generate(),
587            stack_ids::PolicyDecisionId::generate(),
588            None,
589            "starter-tools-test",
590            target_key,
591            stack_ids::ContentDigest::compute(b"test-method"),
592            stack_ids::ContentDigest::compute(b"test-effect"),
593            Some(chrono::Utc::now() + chrono::Duration::minutes(5)),
594            uuid::Uuid::new_v4().to_string(),
595        )
596    }
597
598    fn tool_ctx() -> ToolCtx {
599        ToolCtx {
600            trace_ctx: TraceCtx::generate(),
601            attempt_id: AttemptId::generate(),
602            trial_id: TrialId::generate(),
603            deadline: None,
604            workload_class: None,
605            budget_context: None,
606            scope: Some(ScopeKey::namespace_only("starter-tools-test")),
607            dry_run: true,
608            approval_grant: Some(crate::ApprovalGrant::new(
609                "policy-1",
610                crate::ApprovalGrantScope {
611                    namespace: "starter-tools-test".into(),
612                    target_key: Some("src/lib.rs".into()),
613                    tool_name: "submit_patch".into(),
614                    effect_class: crate::ApprovalGrantEffectClass::Write,
615                    planner_stage: Some(crate::ToolPlannerStage::Execution),
616                },
617                vec!["operator".into()],
618                "2026-03-12T00:00:00Z",
619                None,
620                crate::ApprovalGrantCitation {
621                    applicability_context_id: stack_ids::ApplicabilityContextId::new(
622                        "applicability-context-1",
623                    ),
624                    profile_set_id: stack_ids::ProfileSetId::new("profile-set-1"),
625                    composition_receipt_id: stack_ids::CompositionReceiptId::new(
626                        "composition-receipt-1",
627                    ),
628                    effective_constitution_id: stack_ids::EffectiveConstitutionId::new(
629                        "effective-constitution-1",
630                    ),
631                    compiled_obligation_set_id: stack_ids::CompiledObligationSetId::new(
632                        "compiled-obligation-set-1",
633                    ),
634                },
635            )),
636            execution_permit: Some(execution_permit_for("src/lib.rs").into()),
637            idempotency_key: None,
638            caller: "starter-tools-test".into(),
639            planner_stage: crate::ToolPlannerStage::Execution,
640            parent_receipt_id: None,
641            family_receipt_id: None,
642            replay_parent_receipt_id: None,
643            remote_oracle_lease_id: None,
644            remote_slice_result_id: None,
645            attestation_envelope_id: None,
646            cross_runtime_replay_ticket_id: None,
647            retry_owner: Some(ToolRetryOwner::LlmPipeline),
648        }
649    }
650
651    #[tokio::test]
652    async fn starter_tools_return_expected_results() {
653        let ctx = tool_ctx();
654
655        let search_tool = SearchMemoryTool::new(Arc::new(TestMemorySearch));
656        let search_result = search_tool
657            .invoke(
658                &ctx,
659                &ToolCall::new(
660                    "search_memory",
661                    "1.0.0",
662                    json!({"query": "tool runtime", "limit": 3}),
663                    ToolOriginKind::Test,
664                ),
665            )
666            .await
667            .unwrap();
668        assert_eq!(search_result.payload["query"], "tool runtime");
669        assert_eq!(search_result.payload["limit"], 3);
670
671        let read_tool = ReadArtifactTool::new(Arc::new(TestArtifactReader));
672        let read_result = read_tool
673            .invoke(
674                &ctx,
675                &ToolCall::new(
676                    "read_artifact",
677                    "1.0.0",
678                    json!({"artifact_id": "artifact-1"}),
679                    ToolOriginKind::Test,
680                ),
681            )
682            .await
683            .unwrap();
684        assert_eq!(
685            read_result.display_text.as_deref(),
686            Some("artifact:artifact:artifact-1")
687        );
688
689        let verification_tool = RunVerificationTool::new(Arc::new(TestVerificationPort));
690        let mut verification_ctx = tool_ctx();
691        verification_ctx.execution_permit = Some(execution_permit_for("cand-1").into());
692        let verification_result = verification_tool
693            .invoke(
694                &verification_ctx,
695                &ToolCall::new(
696                    "run_verification",
697                    "1.0.0",
698                    json!({"candidate_id": "cand-1"}),
699                    ToolOriginKind::Test,
700                ),
701            )
702            .await
703            .unwrap();
704        assert!(verification_result.payload["job_id"]
705            .as_str()
706            .is_some_and(|job_id| job_id.starts_with("job:cand-1:")));
707
708        let preview_tool = ApplyPatchPreviewTool::new(Arc::new(TestPatchPreviewPort));
709        let preview_result = preview_tool
710            .invoke(
711                &ctx,
712                &ToolCall::new(
713                    "apply_patch_preview",
714                    "1.0.0",
715                    json!({"target": "src/lib.rs", "patch": "*** Begin Patch"}),
716                    ToolOriginKind::Test,
717                ),
718            )
719            .await
720            .unwrap();
721        assert_eq!(preview_result.payload["preview"], true);
722        assert!(preview_result.payload["execution_permit_id"].is_string());
723
724        let submit_tool = SubmitPatchTool::new(Arc::new(TestSubmitPatchPort));
725        let submit_result = submit_tool
726            .invoke(
727                &ctx,
728                &ToolCall::new(
729                    "submit_patch",
730                    "1.0.0",
731                    json!({"target": "src/lib.rs", "patch": "*** Begin Patch"}),
732                    ToolOriginKind::Test,
733                ),
734            )
735            .await
736            .unwrap();
737        assert_eq!(submit_result.payload["submitted"], true);
738        assert!(submit_result.payload["approval_grant_id"].is_string());
739        assert!(submit_result.payload["execution_permit_id"].is_string());
740    }
741}