1use std::collections::HashMap;
2use std::path::Path;
3use std::pin::Pin;
4use std::sync::Arc;
5use tokio::sync::Mutex;
6
7use async_stream::try_stream;
8use deepstrike_core::context::manager::{KNOWLEDGE_TOOL_NAME, MEMORY_TOOL_NAME};
9use deepstrike_core::context::skill_catalog::SKILL_TOOL_NAME;
10use deepstrike_core::types::message::{Content, ToolCall, ToolResult, ToolSchema};
11use futures::stream::FuturesUnordered;
12use futures::stream::{Stream, StreamExt};
13
14use crate::Result;
15use crate::governance::Governance;
16use crate::knowledge::KnowledgeSource;
17use crate::memory::MemoryStore;
18use crate::run_event::RunEvent;
19use crate::runtime::sandboxed_skill::{
20 PythonSkillPolicy, SkillKind, execute_json_skill, execute_python_skill, resolve_skill_path,
21};
22use crate::tools::{RegisteredTool, ToolChunk, ToolStep, validate_tool_arguments};
23use deepstrike_core::mm::memory::{MemoryQuery, MemoryScope};
24
25#[derive(Clone)]
26pub struct ToolSuspendRequest {
27 pub call_id: String,
28 pub name: String,
29 pub suspension_id: String,
30 pub payload: Option<serde_json::Value>,
31}
32
33pub type ToolSuspendHandler = std::sync::Arc<
34 dyn Fn(ToolSuspendRequest) -> futures::future::BoxFuture<'static, Result<serde_json::Value>>
35 + Send
36 + Sync,
37>;
38
39#[derive(Clone)]
40pub struct PermissionRequest {
41 pub call_id: String,
42 pub tool_name: String,
43 pub arguments: String,
44 pub reason: String,
45}
46
47#[derive(Clone)]
48pub struct PermissionResponse {
49 pub approved: bool,
50 pub responder: String,
51 pub reason: Option<String>,
52}
53
54pub type PermissionRequestHandler = std::sync::Arc<
55 dyn Fn(PermissionRequest) -> futures::future::BoxFuture<'static, Result<PermissionResponse>>
56 + Send
57 + Sync,
58>;
59
60pub struct RunContext<'a> {
62 pub agent_id: Option<&'a str>,
63 pub memory_scope: Option<&'a MemoryScope>,
64 pub skill_dir: Option<&'a Path>,
65 pub memory_store: Option<&'a dyn MemoryStore>,
66 pub knowledge_source: Option<&'a dyn KnowledgeSource>,
67 pub governance: Option<Arc<Mutex<Governance>>>,
68 pub on_tool_suspend: Option<ToolSuspendHandler>,
69 pub on_permission_request: Option<PermissionRequestHandler>,
70}
71
72fn make_result(
73 call_id: compact_str::CompactString,
74 output: String,
75 is_error: bool,
76 is_fatal: bool,
77 error_kind: Option<deepstrike_core::types::message::ToolErrorKind>,
78) -> ToolResult {
79 ToolResult {
80 call_id,
81 output: Content::Text(output),
82 durable_content: None,
83 is_error,
84 is_fatal,
85 error_kind,
86 }
87}
88
89pub trait ExecutionPlane: Send + Sync {
91 fn schemas(&self) -> Vec<ToolSchema>;
92
93 fn execute_all<'a>(
95 &'a self,
96 calls: &'a [ToolCall],
97 ctx: RunContext<'a>,
98 ) -> Pin<Box<dyn Stream<Item = Result<RunEvent>> + Send + 'a>>;
99}
100
101pub struct LocalExecutionPlane {
103 tools: HashMap<String, Arc<RegisteredTool>>,
104}
105
106impl LocalExecutionPlane {
107 pub fn new() -> Self {
108 Self {
109 tools: HashMap::new(),
110 }
111 }
112
113 pub fn register(&mut self, tool: RegisteredTool) -> &mut Self {
114 self.tools
115 .insert(tool.schema.name.to_string(), Arc::new(tool));
116 self
117 }
118
119 pub fn unregister(&mut self, name: &str) -> &mut Self {
120 self.tools.remove(name);
121 self
122 }
123}
124
125impl Default for LocalExecutionPlane {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131impl ExecutionPlane for LocalExecutionPlane {
132 fn schemas(&self) -> Vec<ToolSchema> {
133 self.tools.values().map(|t| t.schema.clone()).collect()
134 }
135
136 fn execute_all<'a>(
137 &'a self,
138 calls: &'a [ToolCall],
139 ctx: RunContext<'a>,
140 ) -> Pin<Box<dyn Stream<Item = Result<RunEvent>> + Send + 'a>> {
141 Box::pin(execute_all_local(self, calls, ctx))
142 }
143}
144
145fn execute_all_local<'a>(
146 plane: &'a LocalExecutionPlane,
147 calls: &'a [ToolCall],
148 ctx: RunContext<'a>,
149) -> Pin<Box<dyn Stream<Item = Result<RunEvent>> + Send + 'a>> {
150 Box::pin(try_stream! {
151 let mut permitted = Vec::new();
152 for c in calls {
153 if let Some(gov) = &ctx.governance {
154 let mut g = gov.lock().await;
155 let now_ms = std::time::SystemTime::now()
156 .duration_since(std::time::UNIX_EPOCH)
157 .unwrap_or_default()
158 .as_millis() as u64;
159 g.set_time(now_ms);
160 let args_str =
161 serde_json::to_string(&c.arguments).unwrap_or_else(|_| "{}".to_string());
162 let verdict = g.evaluate(c.name.as_str(), &args_str);
163 match verdict.kind.as_str() {
164 "deny" => {
165 let reason = verdict.reason.unwrap_or_default();
166 yield RunEvent::ToolDenied {
167 call_id: c.id.to_string(),
168 tool_name: c.name.to_string(),
169 reason: reason.clone(),
170 };
171 yield RunEvent::ToolResult {
172 call_id: c.id.to_string(),
173 content: format!("permission denied: {reason}"),
174 is_error: true,
175 is_fatal: false,
176 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::GovernanceDenied),
177 };
178 continue;
179 }
180 "rate_limited" => {
181 let reason = "rate limited".to_string();
182 yield RunEvent::ToolDenied {
183 call_id: c.id.to_string(),
184 tool_name: c.name.to_string(),
185 reason: reason.clone(),
186 };
187 yield RunEvent::ToolResult {
188 call_id: c.id.to_string(),
189 content: reason,
190 is_error: true,
191 is_fatal: false,
192 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::Recoverable),
193 };
194 continue;
195 }
196 "ask_user" => {
197 let reason = verdict.reason.unwrap_or_default();
198 let args_str = serde_json::to_string(&c.arguments)
199 .unwrap_or_else(|_| "{}".to_string());
200 let request = PermissionRequest {
201 call_id: c.id.to_string(),
202 tool_name: c.name.to_string(),
203 arguments: args_str.clone(),
204 reason: reason.clone(),
205 };
206 yield RunEvent::PermissionRequest {
207 call_id: request.call_id.clone(),
208 tool_name: request.tool_name.clone(),
209 arguments: args_str,
210 reason: reason.clone(),
211 };
212
213 let decision = resolve_permission_request(request, &ctx).await;
214 yield RunEvent::PermissionResolved {
215 call_id: c.id.to_string(),
216 tool_name: c.name.to_string(),
217 approved: decision.approved,
218 responder: decision.responder.clone(),
219 reason: decision.reason.clone(),
220 };
221 if decision.approved {
222 permitted.push(c.clone());
223 continue;
224 }
225
226 let denied_reason = decision.reason.unwrap_or_else(|| {
227 if reason.is_empty() { "permission denied".to_string() } else { reason.clone() }
228 });
229 yield RunEvent::ToolDenied {
230 call_id: c.id.to_string(),
231 tool_name: c.name.to_string(),
232 reason: denied_reason.clone(),
233 };
234 yield RunEvent::ToolResult {
235 call_id: c.id.to_string(),
236 content: format!("permission denied: {denied_reason}"),
237 is_error: true,
238 is_fatal: false,
239 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::GovernanceDenied),
240 };
241 continue;
242 }
243 _ => {}
244 }
245 }
246 permitted.push(c.clone());
247 }
248
249 let skill_calls: Vec<_> = permitted
250 .iter()
251 .filter(|c| c.name.as_str() == SKILL_TOOL_NAME)
252 .cloned()
253 .collect();
254 let memory_calls: Vec<_> = permitted
255 .iter()
256 .filter(|c| c.name.as_str() == MEMORY_TOOL_NAME)
257 .cloned()
258 .collect();
259 let knowledge_calls: Vec<_> = permitted
260 .iter()
261 .filter(|c| c.name.as_str() == KNOWLEDGE_TOOL_NAME)
262 .cloned()
263 .collect();
264 let regular_calls: Vec<_> = permitted
265 .iter()
266 .filter(|c| {
267 !matches!(
268 c.name.as_str(),
269 SKILL_TOOL_NAME | MEMORY_TOOL_NAME | KNOWLEDGE_TOOL_NAME
270 )
271 })
272 .cloned()
273 .collect();
274
275 for c in skill_calls {
276 let name = c.arguments.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
277 let args: std::collections::HashMap<String, serde_json::Value> = c
278 .arguments
279 .as_object()
280 .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
281 .unwrap_or_default();
282
283 let (content, is_error) = if let Some(dir) = ctx.skill_dir {
284 match resolve_skill_path(dir, &name) {
285 Some((path, SkillKind::Prompt)) => {
286 match tokio::fs::read_to_string(&path).await {
287 Ok(content) => (strip_frontmatter(&content).to_string(), false),
288 Err(e) => (format!("error reading skill \"{name}\": {e}"), true),
289 }
290 }
291 Some((path, SkillKind::ComputeJson)) => execute_json_skill(&path, &args),
292 Some((path, SkillKind::PythonScript)) => {
293 execute_python_skill(&path, &args, None, &PythonSkillPolicy::default()).await
294 }
295 None => (format!("Skill \"{name}\" not found."), true),
296 }
297 } else {
298 ("No skill directory configured.".into(), true)
299 };
300 let error_kind = if is_error {
301 Some(deepstrike_core::types::message::ToolErrorKind::Recoverable)
302 } else {
303 None
304 };
305 yield RunEvent::ToolResult {
306 call_id: c.id.to_string(),
307 content,
308 is_error,
309 is_fatal: false,
310 error_kind,
311 };
312 }
313
314 for c in memory_calls {
315 let query = c.arguments.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string();
316 let top_k = c.arguments.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
317 let (content, is_error) = match (ctx.memory_store, ctx.agent_id, ctx.memory_scope) {
318 (Some(store), Some(agent_id), Some(scope)) => match store.search(agent_id, &MemoryQuery {
319 scope: scope.clone(),
320 query,
321 top_k,
322 kinds: Vec::new(),
323 min_score: None,
324 }).await {
325 Ok(entries) if !entries.is_empty() => {
326 let text = entries
327 .iter()
328 .map(|e| format!(
329 "[memory record_id={} score={:.3}] {}",
330 e.record.record_id, e.score, e.record.content
331 ))
332 .collect::<Vec<_>>()
333 .join("\n---\n");
334 (text, false)
335 }
336 Ok(_) => ("No relevant memories found.".into(), false),
337 Err(e) => (format!("Memory search error: {e}"), true),
338 },
339 _ => ("Memory retrieval not configured.".into(), true),
340 };
341 let error_kind = if is_error {
342 Some(deepstrike_core::types::message::ToolErrorKind::Recoverable)
343 } else {
344 None
345 };
346 yield RunEvent::ToolResult {
347 call_id: c.id.to_string(),
348 content,
349 is_error,
350 is_fatal: false,
351 error_kind,
352 };
353 }
354
355 for c in knowledge_calls {
356 let query = c.arguments.get("query").and_then(|v| v.as_str()).unwrap_or("").to_string();
357 let top_k = c.arguments.get("top_k").and_then(|v| v.as_u64()).unwrap_or(5) as usize;
358 let (content, is_error) = if let Some(ks) = ctx.knowledge_source {
359 match ks.retrieve(&query, top_k).await {
360 Ok(snippets) if !snippets.is_empty() => (snippets.join("\n---\n"), false),
361 Ok(_) => ("No relevant knowledge found.".into(), false),
362 Err(e) => (format!("Knowledge retrieval error: {e}"), true),
363 }
364 } else {
365 ("Knowledge source not configured.".into(), true)
366 };
367 let error_kind = if is_error {
368 Some(deepstrike_core::types::message::ToolErrorKind::Recoverable)
369 } else {
370 None
371 };
372 yield RunEvent::ToolResult {
373 call_id: c.id.to_string(),
374 content,
375 is_error,
376 is_fatal: false,
377 error_kind,
378 };
379 }
380
381 if regular_calls.is_empty() {
382 return;
383 }
384
385 struct ActiveTool {
386 call_id: compact_str::CompactString,
387 name: String,
388 session: Box<dyn crate::tools::ToolSession>,
389 resume_input: Option<serde_json::Value>,
390 combined: String,
391 }
392
393 let mut active: FuturesUnordered<
394 futures::future::BoxFuture<'_, (ActiveTool, crate::Result<ToolStep>)>,
395 > = FuturesUnordered::new();
396
397 for mut call in regular_calls {
398 let Some(tool) = plane.tools.get(call.name.as_str()) else {
399 let content = format!("unknown tool: {}", call.name);
400 yield RunEvent::ToolResult {
401 call_id: call.id.to_string(),
402 content: content.clone(),
403 is_error: true,
404 is_fatal: false,
405 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::Recoverable),
406 };
407 continue;
408 };
409 let original_args_str = serde_json::to_string(&call.arguments).unwrap_or_default();
410 match validate_tool_arguments(&tool.schema.parameters, &mut call.arguments) {
411 Ok(repaired) => {
412 if repaired {
413 let repaired_args_str = serde_json::to_string(&call.arguments).unwrap_or_default();
414 yield RunEvent::ToolArgumentRepaired {
415 call_id: call.id.to_string(),
416 name: call.name.to_string(),
417 original_arguments: original_args_str,
418 repaired_arguments: repaired_args_str,
419 };
420 }
421 }
422 Err(e) => {
423 let content = format!("invalid arguments: {e}");
424 yield RunEvent::ToolResult {
425 call_id: call.id.to_string(),
426 content: content.clone(),
427 is_error: true,
428 is_fatal: false,
429 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::Recoverable),
430 };
431 continue;
432 }
433 }
434 let start = Arc::clone(&tool.start);
435 let args = call.arguments.clone();
436 let call_id = call.id.clone();
437 let name = call.name.to_string();
438 match start(args).await {
439 Ok(session) => {
440 let active_tool = ActiveTool {
441 call_id: call_id.clone(),
442 name: name.clone(),
443 session,
444 resume_input: None,
445 combined: String::new(),
446 };
447 active.push(Box::pin(async move {
448 let mut t = active_tool;
449 let step = t.session.next(t.resume_input.take()).await;
450 (t, step)
451 }));
452 }
453 Err(e) => {
454 let (is_fatal, error_kind) = match &e {
455 crate::Error::ToolExecutionFailed { is_fatal, error_kind, .. } => (*is_fatal, *error_kind),
456 crate::Error::ToolFail { is_fatal, error_kind, .. } => (*is_fatal, *error_kind),
457 _ => (false, Some(deepstrike_core::types::message::ToolErrorKind::Recoverable)),
458 };
459 yield RunEvent::ToolResult {
460 call_id: call_id.to_string(),
461 content: crate::format_tool_error(&e),
462 is_error: true,
463 is_fatal,
464 error_kind,
465 };
466 }
467 }
468 }
469
470 while let Some((mut tool, step)) = active.next().await {
471 match step {
472 Ok(ToolStep::Chunk(chunk)) => {
473 match &chunk {
474 ToolChunk::Suspend { suspension_id, payload } => {
475 yield RunEvent::ToolSuspend {
476 call_id: tool.call_id.to_string(),
477 name: tool.name.clone(),
478 suspension_id: suspension_id.clone(),
479 payload: payload.clone(),
480 };
481 match &ctx.on_tool_suspend {
482 Some(handler) => {
483 tool.resume_input = Some(
484 handler(ToolSuspendRequest {
485 call_id: tool.call_id.to_string(),
486 name: tool.name.clone(),
487 suspension_id: suspension_id.clone(),
488 payload: payload.clone(),
489 })
490 .await?,
491 );
492 }
493 None => {
494 let content = format!(
495 "tool suspended without resume handler: {suspension_id}"
496 );
497 yield RunEvent::ToolResult {
498 call_id: tool.call_id.to_string(),
499 content: content.clone(),
500 is_error: true,
501 is_fatal: false,
502 error_kind: Some(deepstrike_core::types::message::ToolErrorKind::Recoverable),
503 };
504 continue;
505 }
506 }
507 }
508 _ => {
509 tool.combined.push_str(chunk.text_projection());
510 yield RunEvent::ToolDelta {
511 call_id: tool.call_id.to_string(),
512 name: tool.name.clone(),
513 chunk,
514 };
515 }
516 }
517 active.push(Box::pin(async move {
518 let step = tool.session.next(tool.resume_input.take()).await;
519 (tool, step)
520 }));
521 }
522 Ok(ToolStep::Done(text)) => {
523 tool.combined.push_str(&text);
524 yield RunEvent::ToolResult {
525 call_id: tool.call_id.to_string(),
526 content: tool.combined.clone(),
527 is_error: false,
528 is_fatal: false,
529 error_kind: None,
530 };
531 }
532 Err(e) => {
533 let (is_fatal, error_kind) = match &e {
534 crate::Error::ToolExecutionFailed { is_fatal, error_kind, .. } => (*is_fatal, *error_kind),
535 crate::Error::ToolFail { is_fatal, error_kind, .. } => (*is_fatal, *error_kind),
536 _ => (false, Some(deepstrike_core::types::message::ToolErrorKind::Recoverable)),
537 };
538 yield RunEvent::ToolResult {
539 call_id: tool.call_id.to_string(),
540 content: crate::format_tool_error(&e),
541 is_error: true,
542 is_fatal,
543 error_kind,
544 };
545 }
546 }
547 }
548 })
549}
550
551fn strip_frontmatter(content: &str) -> &str {
552 let s = content.trim_start();
553 if !s.starts_with("---") {
554 return s;
555 }
556 let rest = &s[3..];
557 if let Some(end) = rest.find("\n---") {
558 rest[end + 4..].trim_start_matches('\n')
559 } else {
560 s
561 }
562}
563
564async fn resolve_permission_request(
565 request: PermissionRequest,
566 ctx: &RunContext<'_>,
567) -> PermissionResponse {
568 let Some(handler) = &ctx.on_permission_request else {
569 return PermissionResponse {
570 approved: false,
571 responder: "policy_gate".to_string(),
572 reason: Some("no permission handler configured".to_string()),
573 };
574 };
575
576 match handler(request).await {
577 Ok(response) => PermissionResponse {
578 approved: response.approved,
579 responder: if response.responder.is_empty() {
580 "host".to_string()
581 } else {
582 response.responder
583 },
584 reason: response.reason,
585 },
586 Err(err) => PermissionResponse {
587 approved: false,
588 responder: "permission_handler".to_string(),
589 reason: Some(format!("permission handler failed: {err}")),
590 },
591 }
592}
593
594pub async fn collect_tool_results(
596 mut stream: Pin<Box<dyn Stream<Item = Result<RunEvent>> + Send>>,
597 calls: &[ToolCall],
598) -> Result<Vec<ToolResult>> {
599 let mut by_id: HashMap<String, ToolResult> = HashMap::new();
600 while let Some(evt) = stream.next().await {
601 if let RunEvent::ToolResult {
602 call_id,
603 content,
604 is_error,
605 is_fatal,
606 error_kind,
607 } = evt?
608 {
609 by_id.insert(
610 call_id.clone(),
611 make_result(
612 compact_str::CompactString::new(&call_id),
613 content,
614 is_error,
615 is_fatal,
616 error_kind,
617 ),
618 );
619 }
620 }
621 Ok(calls
622 .iter()
623 .filter_map(|c| by_id.remove(c.id.as_str()))
624 .collect())
625}