Skip to main content

vtcode_core/core/agent/
request_envelope.rs

1//! Immutable, cache-stable request envelope for one harness segment.
2
3use std::cmp::Ordering;
4use std::sync::Arc;
5
6use crate::llm::provider::ToolDefinition;
7
8use super::hash_utils::{hash_tool_definitions, hash_value};
9
10/// A change that intentionally ends the current cache-stable segment.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum SegmentBoundaryReason {
13    Compaction,
14    Recovery,
15    ToolCatalogEpoch,
16    PrimaryAgent,
17    Model,
18    Provider,
19    Mode,
20    Instructions,
21}
22
23impl SegmentBoundaryReason {
24    #[must_use]
25    pub const fn as_str(self) -> &'static str {
26        match self {
27            Self::Compaction => "compaction",
28            Self::Recovery => "recovery",
29            Self::ToolCatalogEpoch => "tool_catalog_epoch",
30            Self::PrimaryAgent => "primary_agent",
31            Self::Model => "model",
32            Self::Provider => "provider",
33            Self::Mode => "mode",
34            Self::Instructions => "instructions",
35        }
36    }
37}
38
39/// Immutable request prefix shared by every request in a session segment.
40#[derive(Debug, Clone)]
41pub struct SessionRequestEnvelope {
42    segment_id: Arc<str>,
43    system_prompt: Arc<str>,
44    ordered_tools: Arc<Vec<ToolDefinition>>,
45    instruction_digest: u64,
46    prefix_hash: u64,
47    catalog_hash: Option<u64>,
48}
49
50impl SessionRequestEnvelope {
51    /// Freeze a segment prefix. Tool ordering is canonicalized once here and
52    /// must not be rebuilt by individual turns.
53    #[must_use]
54    pub fn new(
55        segment_id: impl Into<Arc<str>>,
56        system_prompt: impl Into<Arc<str>>,
57        tools: Vec<ToolDefinition>,
58        instruction_digest: u64,
59    ) -> Self {
60        let system_prompt = system_prompt.into();
61        let prefix_hash = hash_value(&system_prompt);
62        Self::with_prefix_hash(segment_id, system_prompt, tools, instruction_digest, prefix_hash)
63    }
64
65    /// Freeze a segment prefix while retaining a caller-provided instruction
66    /// digest for compatibility with request identity tracking.
67    #[must_use]
68    pub fn with_prefix_hash(
69        segment_id: impl Into<Arc<str>>,
70        system_prompt: impl Into<Arc<str>>,
71        mut tools: Vec<ToolDefinition>,
72        instruction_digest: u64,
73        prefix_hash: u64,
74    ) -> Self {
75        tools.sort_by(compare_tools);
76        let system_prompt = system_prompt.into();
77        let catalog_hash = if tools.is_empty() {
78            None
79        } else {
80            hash_tool_definitions(Some(&tools))
81        };
82        Self {
83            segment_id: segment_id.into(),
84            system_prompt,
85            ordered_tools: Arc::new(tools),
86            instruction_digest,
87            prefix_hash,
88            catalog_hash,
89        }
90    }
91
92    #[must_use]
93    pub fn segment_id(&self) -> &str {
94        &self.segment_id
95    }
96
97    #[must_use]
98    pub fn system_prompt(&self) -> Arc<str> {
99        Arc::clone(&self.system_prompt)
100    }
101
102    #[must_use]
103    pub fn ordered_tools(&self) -> Arc<Vec<ToolDefinition>> {
104        Arc::clone(&self.ordered_tools)
105    }
106
107    #[must_use]
108    pub fn instruction_digest(&self) -> u64 {
109        self.instruction_digest
110    }
111
112    #[must_use]
113    pub fn prefix_hash(&self) -> u64 {
114        self.prefix_hash
115    }
116
117    /// Include the resolved capability digest without changing frozen prompt bytes.
118    #[must_use]
119    pub fn with_capability_digest(mut self, digest: u64) -> Self {
120        self.prefix_hash = hash_value(&(self.prefix_hash, digest));
121        self
122    }
123
124    #[must_use]
125    pub fn catalog_hash(&self) -> Option<u64> {
126        self.catalog_hash
127    }
128
129    /// Start a new immutable continuity segment while preserving the exact
130    /// prompt and tool-catalog identity used by subsequent requests.
131    #[must_use]
132    pub fn begin_segment(&self, segment_id: impl Into<Arc<str>>) -> Self {
133        let mut next = self.clone();
134        next.segment_id = segment_id.into();
135        next
136    }
137}
138
139fn core_priority(name: &str) -> u8 {
140    match name {
141        "exec_command" => 0,
142        "write_stdin" => 1,
143        "search_tools" | "mcp_search_tools" => 2,
144        "apply_patch" => 3,
145        _ => 4,
146    }
147}
148
149fn compare_tools(left: &ToolDefinition, right: &ToolDefinition) -> Ordering {
150    let left_name = left.function_name();
151    let right_name = right.function_name();
152    core_priority(left_name)
153        .cmp(&core_priority(right_name))
154        .then_with(|| left_name.cmp(right_name))
155        .then_with(|| left.tool_type.cmp(&right.tool_type))
156}
157
158#[cfg(test)]
159mod tests {
160    use serde_json::json;
161
162    use super::*;
163
164    fn tool(name: &str) -> ToolDefinition {
165        ToolDefinition::function(name.to_string(), name.to_string(), json!({"type": "object"}))
166    }
167
168    #[test]
169    fn equivalent_cold_starts_are_byte_identical() {
170        let left = SessionRequestEnvelope::new(
171            "segment-1",
172            "fixed prompt",
173            vec![tool("zeta"), tool("exec_command"), tool("alpha")],
174            42,
175        );
176        let right = SessionRequestEnvelope::new(
177            "segment-1",
178            "fixed prompt",
179            vec![tool("alpha"), tool("zeta"), tool("exec_command")],
180            42,
181        );
182
183        assert_eq!(left.system_prompt.as_bytes(), right.system_prompt.as_bytes());
184        assert_eq!(
185            serde_json::to_vec(left.ordered_tools.as_ref()).expect("serialize left catalog"),
186            serde_json::to_vec(right.ordered_tools.as_ref()).expect("serialize right catalog")
187        );
188        assert_eq!(left.catalog_hash, right.catalog_hash);
189    }
190
191    #[test]
192    fn core_priority_precedes_name_order() {
193        let envelope = SessionRequestEnvelope::new(
194            "segment-1",
195            "fixed prompt",
196            vec![
197                tool("alpha"),
198                tool("apply_patch"),
199                tool("search_tools"),
200                tool("exec_command"),
201            ],
202            42,
203        );
204        let names = envelope
205            .ordered_tools
206            .iter()
207            .map(ToolDefinition::function_name)
208            .collect::<Vec<_>>();
209
210        assert_eq!(names, ["exec_command", "search_tools", "apply_patch", "alpha"]);
211    }
212
213    #[test]
214    fn constructor_preserves_instruction_digest_and_prefix_override() {
215        let envelope = SessionRequestEnvelope::new("segment-1", "fixed prompt", vec![], 42);
216        assert_eq!(envelope.instruction_digest(), 42);
217        assert_eq!(envelope.prefix_hash(), hash_value(&"fixed prompt"));
218
219        let stable = SessionRequestEnvelope::with_prefix_hash("segment-1", "runtime prompt", vec![], 42, 7);
220        assert_eq!(stable.instruction_digest(), 42);
221        assert_eq!(stable.prefix_hash(), 7);
222    }
223
224    #[test]
225    fn new_segment_preserves_frozen_request_identity() {
226        let previous = SessionRequestEnvelope::new("segment-1", "fixed prompt", vec![tool("alpha")], 42);
227        let next = previous.begin_segment("segment-2");
228
229        assert_eq!(previous.segment_id(), "segment-1");
230        assert_eq!(next.segment_id(), "segment-2");
231        assert_eq!(previous.system_prompt().as_ref(), next.system_prompt().as_ref());
232        assert_eq!(previous.instruction_digest(), next.instruction_digest());
233        assert_eq!(previous.prefix_hash(), next.prefix_hash());
234        assert_eq!(previous.catalog_hash(), next.catalog_hash());
235    }
236
237    #[test]
238    fn begin_segment_shares_prompt_and_catalog_without_deep_clone() {
239        // A new segment must not rebuild the prompt/tool catalog: the frozen
240        // `Arc<str>`/`Arc<Vec<ToolDefinition>>` owners are shared so the wire
241        // prefix stays byte-identical and no deep clone is paid.
242        let previous =
243            SessionRequestEnvelope::new("segment-1", "fixed prompt", vec![tool("alpha"), tool("exec_command")], 42);
244        let next = previous.begin_segment("segment-2");
245
246        assert!(Arc::ptr_eq(&previous.system_prompt(), &next.system_prompt()));
247        assert!(Arc::ptr_eq(&previous.ordered_tools(), &next.ordered_tools()));
248    }
249}