Skip to main content

speccoder_runtime/
context.rs

1//! The context bundle for one task.
2//!
3//! Built from files *before* a run and passed as the run's input, so the
4//! journal holds exactly what the model saw and replay needs no files.
5//! Deterministic: same files, same bundle, same bytes.
6
7use serde::Serialize;
8use speccoder_core::snapshot::ProjectSnapshot;
9
10/// Largest share of the prompt one document may take.
11pub const PART_LIMIT: usize = 32 * 1024;
12
13/// What the model is given about a task.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
15pub struct Bundle {
16    /// Feature directory name.
17    pub feature: String,
18    /// The project constitution (may be empty).
19    pub constitution: String,
20    /// `plan.md` (may be empty).
21    pub plan: String,
22    /// The task's user-story section of `spec.md`; the whole spec for a
23    /// Convergence task; or empty.
24    pub story: String,
25    /// The task.
26    pub task: TaskContext,
27    /// Its direct dependencies, in document order.
28    pub dependencies: Vec<DependencyContext>,
29}
30
31/// The task as written.
32#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
33pub struct TaskContext {
34    /// Task ID.
35    pub id: String,
36    /// Title after markers.
37    pub title: String,
38    /// Enclosing phase heading.
39    pub phase: String,
40    /// Enclosing subsection, if any.
41    pub subsection: String,
42    /// Story label, if any.
43    pub story: String,
44    /// Carries `[P]`.
45    pub parallel: bool,
46}
47
48/// A dependency of the task.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50pub struct DependencyContext {
51    /// Task ID.
52    pub id: String,
53    /// Title.
54    pub title: String,
55    /// Ticked in `tasks.md`.
56    pub done: bool,
57}
58
59/// Why no bundle could be built.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum BundleError {
62    /// No feature selected.
63    NoFeature,
64    /// The feature has no task list.
65    NoTasks,
66    /// The task ID is not in the list.
67    NotFound(String),
68}
69
70/// Builds the bundle for `task_id` of the selected feature of a project in
71/// the reference format.
72pub fn bundle(snapshot: &ProjectSnapshot, task_id: &str) -> Result<Bundle, BundleError> {
73    crate::format::reference().bundle(snapshot, task_id)
74}
75
76/// Cuts text at `PART_LIMIT` bytes on a char boundary, with a marker.
77pub(crate) fn cap(text: &str) -> String {
78    if text.len() <= PART_LIMIT {
79        return text.to_owned();
80    }
81    let mut end = PART_LIMIT;
82    while !text.is_char_boundary(end) {
83        end -= 1;
84    }
85    format!(
86        "{}\n[… cut at {PART_LIMIT} bytes of {} …]",
87        &text[..end],
88        text.len()
89    )
90}
91
92/// The one builder of a call's context manifest (constitution VIII): every
93/// part handed to a model call is pushed here, hashed and labelled once.
94#[derive(Debug, Default, Clone)]
95pub struct Manifest {
96    entries: Vec<speccoder_core::context::ContextEntry>,
97}
98
99impl Manifest {
100    /// An empty manifest.
101    #[must_use]
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// Adds a part: what it is, where it came from, a reference to it and
107    /// its exact bytes (an empty part is not added).
108    pub fn push(
109        &mut self,
110        kind: speccoder_core::context::ContextKind,
111        source: &str,
112        reference: &str,
113        bytes: &[u8],
114    ) -> &mut Self {
115        if bytes.is_empty() {
116            return self;
117        }
118        self.entries.push(speccoder_core::context::ContextEntry {
119            kind,
120            source: source.to_owned(),
121            reference: reference.to_owned(),
122            sha256: crate::integrity::sha256_hex(bytes),
123            bytes: u64::try_from(bytes.len()).unwrap_or(u64::MAX),
124            cut: bytes.len() > PART_LIMIT,
125        });
126        self
127    }
128
129    /// The finished manifest, sorted, with its digest.
130    #[must_use]
131    pub fn finish(&self) -> speccoder_core::context::ContextManifest {
132        speccoder_core::context::ContextManifest::of(
133            self.entries.clone(),
134            crate::integrity::sha256_hex,
135        )
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn a_manifest_recomputes_from_what_it_lists() {
145        use speccoder_core::context::ContextKind;
146        let mut m = Manifest::new();
147        m.push(ContextKind::Issue, "github:o/r#42", "#42", b"title\nbody")
148            .push(ContextKind::Story, "person", "/triad", b"C\tx\n")
149            .push(ContextKind::Plan, "worktree", "/bundle/plan", b"");
150        let done = m.finish();
151        assert_eq!(done.entries.len(), 2, "an empty part is not listed");
152        let again = speccoder_core::context::ContextManifest::of(
153            done.entries.clone(),
154            crate::integrity::sha256_hex,
155        );
156        assert_eq!(again.digest, done.digest);
157        assert_eq!(
158            done.entries[0].sha256,
159            crate::integrity::sha256_hex(b"C\tx\n")
160        );
161    }
162
163    #[test]
164    fn cap_marks_the_cut() {
165        let long = "é".repeat(PART_LIMIT);
166        let capped = cap(&long);
167        assert!(capped.len() < long.len());
168        assert!(
169            capped.contains("[… cut at 32768 bytes of 65536 …]"),
170            "{}",
171            &capped[capped.len() - 40..]
172        );
173    }
174}