Skip to main content

systemprompt_models/ai/
execution_plan.rs

1//! Multi-step tool-execution planning and result tracking.
2//!
3//! A [`PlanningResult`] is either a direct response or a sequence of
4//! [`PlannedToolCall`]s. As calls run, [`ExecutionState`] accumulates
5//! [`ToolCallResult`]s and halts on the first failure. [`TemplateRef`] parses
6//! the `$N.output.field` references that let a later call consume an earlier
7//! call's output.
8//!
9//! Copyright (c) systemprompt.io — Business Source License 1.1.
10//! See <https://systemprompt.io> for licensing details.
11
12use std::sync::LazyLock;
13
14use regex::Regex;
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(tag = "type", rename_all = "snake_case")]
20pub enum PlanningResult {
21    DirectResponse {
22        content: String,
23    },
24    ToolCalls {
25        reasoning: String,
26        calls: Vec<PlannedToolCall>,
27    },
28}
29
30impl PlanningResult {
31    pub fn direct_response(content: impl Into<String>) -> Self {
32        Self::DirectResponse {
33            content: content.into(),
34        }
35    }
36
37    pub fn tool_calls(reasoning: impl Into<String>, calls: Vec<PlannedToolCall>) -> Self {
38        Self::ToolCalls {
39            reasoning: reasoning.into(),
40            calls,
41        }
42    }
43
44    pub const fn is_direct(&self) -> bool {
45        matches!(self, Self::DirectResponse { .. })
46    }
47
48    pub const fn is_tool_calls(&self) -> bool {
49        matches!(self, Self::ToolCalls { .. })
50    }
51
52    pub const fn tool_count(&self) -> usize {
53        match self {
54            Self::DirectResponse { .. } => 0,
55            Self::ToolCalls { calls, .. } => calls.len(),
56        }
57    }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct PlannedToolCall {
62    pub tool_name: String,
63    // JSON: MCP tool-call arguments / result are the tool's own JSON.
64    pub arguments: Value,
65}
66
67impl PlannedToolCall {
68    // JSON: MCP tool-call arguments / result are the tool's own JSON.
69    pub fn new(tool_name: impl Into<String>, arguments: Value) -> Self {
70        Self {
71            tool_name: tool_name.into(),
72            arguments,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub struct ToolCallResult {
79    pub tool_name: String,
80    // JSON: MCP tool-call arguments / result are the tool's own JSON.
81    pub arguments: Value,
82    pub success: bool,
83    // JSON: MCP tool-call arguments / result are the tool's own JSON.
84    pub output: Value,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    // JSON: MCP `_meta` is an open map of vendor-prefixed keys.
87    pub meta: Option<Value>,
88    pub error: Option<String>,
89    pub duration_ms: u64,
90}
91
92impl ToolCallResult {
93    pub const fn success(
94        tool_name: String,
95        // JSON: MCP tool-call arguments / result are the tool's own JSON.
96        arguments: Value,
97        // JSON: MCP tool-call arguments / result are the tool's own JSON.
98        output: Value,
99        duration_ms: u64,
100    ) -> Self {
101        Self {
102            tool_name,
103            arguments,
104            success: true,
105            output,
106            meta: None,
107            error: None,
108            duration_ms,
109        }
110    }
111
112    #[must_use]
113    // JSON: MCP `_meta` is an open map of vendor-prefixed keys.
114    pub fn with_meta(mut self, meta: Option<Value>) -> Self {
115        self.meta = meta;
116        self
117    }
118
119    pub fn failure(
120        tool_name: String,
121        // JSON: MCP tool-call arguments / result are the tool's own JSON.
122        arguments: Value,
123        error: impl Into<String>,
124        duration_ms: u64,
125    ) -> Self {
126        Self {
127            tool_name,
128            arguments,
129            success: false,
130            output: Value::Null,
131            meta: None,
132            error: Some(error.into()),
133            duration_ms,
134        }
135    }
136}
137
138#[derive(Debug, Clone, Default, Serialize, Deserialize)]
139pub struct ExecutionState {
140    pub results: Vec<ToolCallResult>,
141    pub halted: bool,
142    pub halt_reason: Option<String>,
143}
144
145impl ExecutionState {
146    pub fn new() -> Self {
147        Self::default()
148    }
149
150    pub fn add_result(&mut self, result: ToolCallResult) {
151        if !result.success && !self.halted {
152            self.halted = true;
153            self.halt_reason.clone_from(&result.error);
154        }
155        self.results.push(result);
156    }
157
158    pub fn successful_results(&self) -> Vec<&ToolCallResult> {
159        self.results.iter().filter(|r| r.success).collect()
160    }
161
162    pub fn failed_results(&self) -> Vec<&ToolCallResult> {
163        self.results.iter().filter(|r| !r.success).collect()
164    }
165
166    pub fn total_duration_ms(&self) -> u64 {
167        self.results.iter().map(|r| r.duration_ms).sum()
168    }
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct TemplateRef {
173    pub tool_index: usize,
174    pub field_path: Vec<String>,
175}
176
177#[expect(
178    clippy::expect_used,
179    reason = "compile-time-constant regex; failure is a programmer bug, not runtime input"
180)]
181static TEMPLATE_REF_REGEX: LazyLock<Regex> = LazyLock::new(|| {
182    Regex::new(r"^\$(\d+)\.output\.(.+)$")
183        .expect("TEMPLATE_REF_REGEX is a valid regex - this is a compile-time constant")
184});
185
186impl TemplateRef {
187    pub fn parse(template: &str) -> Option<Self> {
188        let caps = TEMPLATE_REF_REGEX.captures(template)?;
189
190        let tool_index = caps.get(1)?.as_str().parse().ok()?;
191        let path = caps.get(2)?.as_str();
192        let field_path = path.split('.').map(String::from).collect();
193
194        Some(Self {
195            tool_index,
196            field_path,
197        })
198    }
199
200    pub fn format(&self) -> String {
201        format!("${}.output.{}", self.tool_index, self.field_path.join("."))
202    }
203}