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