Skip to main content

deepstrike_sdk/runtime/
remote_vpc_plane.rs

1use std::collections::HashSet;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use async_stream::try_stream;
6use deepstrike_core::types::message::{ToolCall, ToolSchema};
7use futures::StreamExt;
8use futures::stream::Stream;
9
10use crate::Result;
11use crate::run_event::RunEvent;
12use crate::runtime::credential_vault::CredentialVault;
13use crate::runtime::execution_plane::{ExecutionPlane, LocalExecutionPlane, RunContext};
14use crate::tools::RegisteredTool;
15
16pub struct RemoteVpcOptions {
17    /// Base URL of the remote worker endpoint inside the customer VPC.
18    /// Expected route: `POST {base_url}/execute`  body: `{ name, arguments }`
19    ///                                            response: `{ output, isError }`
20    pub base_url: String,
21    pub vault: Arc<dyn CredentialVault>,
22    /// Vault key whose value is sent verbatim as the `Authorization` header.
23    /// Fetched fresh on every `execute_all` call — never stored in the session log.
24    pub auth_credential_key: Option<String>,
25    /// Static tool schemas served by this VPC worker.
26    pub schemas: Vec<ToolSchema>,
27    /// Per-call HTTP timeout in ms. Default: 30 000.
28    pub timeout_ms: u64,
29}
30
31/// `ExecutionPlane` that forwards tool calls over HTTP to a worker inside a customer VPC.
32///
33/// Credentials are fetched from a `CredentialVault` at call time and injected into the
34/// `Authorization` header — they are never forwarded to the model or stored in the session log.
35///
36/// Local tools registered via `register()` run in-process and take priority over any remote
37/// schema with the same name.
38pub struct RemoteVpcPlane {
39    base_url: String,
40    vault: Arc<dyn CredentialVault>,
41    auth_key: Option<String>,
42    remote_schemas: Vec<ToolSchema>,
43    client: reqwest::Client,
44    local: LocalExecutionPlane,
45}
46
47impl RemoteVpcPlane {
48    pub fn new(opts: RemoteVpcOptions) -> Self {
49        let client = reqwest::Client::builder()
50            .timeout(std::time::Duration::from_millis(opts.timeout_ms))
51            .build()
52            .unwrap_or_default();
53        Self {
54            base_url: opts.base_url.trim_end_matches('/').to_string(),
55            vault: opts.vault,
56            auth_key: opts.auth_credential_key,
57            remote_schemas: opts.schemas,
58            client,
59            local: LocalExecutionPlane::new(),
60        }
61    }
62
63    /// Register an in-process tool. Local tools take priority over remote schemas of the same name.
64    pub fn register(&mut self, tool: RegisteredTool) -> &mut Self {
65        self.local.register(tool);
66        self
67    }
68
69    pub fn unregister(&mut self, name: &str) -> &mut Self {
70        self.local.unregister(name);
71        self
72    }
73}
74
75impl ExecutionPlane for RemoteVpcPlane {
76    fn schemas(&self) -> Vec<ToolSchema> {
77        let local_names: HashSet<_> = self
78            .local
79            .schemas()
80            .into_iter()
81            .map(|s| s.name.clone())
82            .collect();
83        let mut result = self.local.schemas();
84        result.extend(
85            self.remote_schemas
86                .iter()
87                .filter(|s| !local_names.contains(&s.name))
88                .cloned(),
89        );
90        result
91    }
92
93    fn execute_all<'a>(
94        &'a self,
95        calls: &'a [ToolCall],
96        ctx: RunContext<'a>,
97    ) -> Pin<Box<dyn Stream<Item = Result<RunEvent>> + Send + 'a>> {
98        Box::pin(try_stream! {
99            let RunContext {
100                agent_id, skill_dir, dream_store, knowledge_source, governance, on_tool_suspend, on_permission_request,
101            } = ctx;
102
103            let local_names: HashSet<String> =
104                self.local.schemas().into_iter().map(|s| s.name.to_string()).collect();
105            let local_calls: Vec<_> =
106                calls.iter().filter(|c| local_names.contains(c.name.as_str())).cloned().collect();
107            let remote_calls: Vec<_> =
108                calls.iter().filter(|c| !local_names.contains(c.name.as_str())).cloned().collect();
109
110            if !local_calls.is_empty() {
111                let local_ctx = RunContext {
112                    agent_id,
113                    skill_dir,
114                    dream_store,
115                    knowledge_source,
116                    governance: governance.clone(),
117                    on_tool_suspend: on_tool_suspend.clone(),
118                    on_permission_request: on_permission_request.clone(),
119                };
120                let mut s = self.local.execute_all(&local_calls, local_ctx);
121                while let Some(evt) = s.next().await {
122                    yield evt?;
123                }
124            }
125
126            if !remote_calls.is_empty() {
127                let auth = if let Some(key) = &self.auth_key {
128                    self.vault.get(key).await
129                } else {
130                    None
131                };
132
133                // Fire all remote calls concurrently; yield results in dispatch order.
134                let futs: Vec<_> = remote_calls
135                    .iter()
136                    .map(|call| {
137                        let client = self.client.clone();
138                        let url = format!("{}/execute", self.base_url);
139                        let auth = auth.clone();
140                        let call = call.clone();
141                        async move { call_remote(client, url, auth, call).await }
142                    })
143                    .collect();
144
145                let results = futures::future::join_all(futs).await;
146                for (call, (content, is_error)) in remote_calls.iter().zip(results) {
147                    yield RunEvent::ToolResult {
148                        call_id: call.id.to_string(),
149                        content,
150                        is_error,
151                        is_fatal: false,
152                        error_kind: None,
153                    };
154                }
155            }
156        })
157    }
158}
159
160// ── HTTP helper ───────────────────────────────────────────────────────────────
161
162async fn call_remote(
163    client: reqwest::Client,
164    url: String,
165    auth: Option<String>,
166    call: ToolCall,
167) -> (String, bool) {
168    let mut req = client.post(&url).json(&serde_json::json!({
169        "name": call.name.as_str(),
170        "arguments": call.arguments,
171    }));
172    if let Some(token) = auth {
173        req = req.header("Authorization", token);
174    }
175
176    match req.send().await {
177        Ok(resp) => {
178            if !resp.status().is_success() {
179                let status = resp.status().as_u16();
180                let body = resp.text().await.unwrap_or_default();
181                let msg = if body.is_empty() {
182                    format!("HTTP {status}")
183                } else {
184                    format!("HTTP {status}: {body}")
185                };
186                return (msg, true);
187            }
188            match resp.json::<serde_json::Value>().await {
189                Ok(result) => {
190                    let output = result
191                        .get("output")
192                        .and_then(|v| v.as_str())
193                        .unwrap_or("")
194                        .to_string();
195                    let is_error = result
196                        .get("isError")
197                        .and_then(|v| v.as_bool())
198                        .unwrap_or(false);
199                    (output, is_error)
200                }
201                Err(e) => (e.to_string(), true),
202            }
203        }
204        Err(e) => (e.to_string(), true),
205    }
206}