Skip to main content

git_internal/internal/object/
context_frame.rs

1//! Immutable context-frame event object.
2//!
3//! `ContextFrame` stores one durable piece of incremental workflow
4//! context.
5//!
6//! # How to use this object
7//!
8//! - Create one frame whenever an incremental context fact should
9//!   survive history: intent analysis, step summary, code change,
10//!   checkpoint, tool call, or recovery note.
11//! - Attach `intent_id`, `run_id`, `plan_id`, and `step_id` when known
12//!   so the frame can be joined back to analysis or execution history.
13//! - Persist each frame independently instead of mutating a shared
14//!   pipeline object.
15//!
16//! # How it works with other objects
17//!
18//! - `Intent.analysis_context_frames` freezes the analysis-time context
19//!   set used to derive one `IntentSpec` revision.
20//! - `Plan.context_frames` freezes the planning-time context set.
21//! - `PlanStepEvent.consumed_frames` and `produced_frames` express
22//!   runtime context flow.
23//! - Libra's live context window is a projection over stored frame IDs.
24//!
25//! # How Libra should call it
26//!
27//! Libra should store every durable context increment as its own
28//! `ContextFrame`, then maintain a separate in-memory or database-backed
29//! window of which frame IDs are currently active.
30
31use std::fmt;
32
33use serde::{Deserialize, Serialize};
34use uuid::Uuid;
35
36use crate::{
37    errors::GitError,
38    hash::ObjectHash,
39    internal::object::{
40        ObjectTrait,
41        types::{ActorRef, Header, ObjectType},
42    },
43};
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
46#[serde(rename_all = "snake_case")]
47pub enum FrameKind {
48    IntentAnalysis,
49    StepSummary,
50    CodeChange,
51    SystemState,
52    ErrorRecovery,
53    Checkpoint,
54    ToolCall,
55    Other(String),
56}
57
58impl FrameKind {
59    /// Return the canonical snake_case storage/display form for the
60    /// frame kind.
61    pub fn as_str(&self) -> &str {
62        match self {
63            FrameKind::IntentAnalysis => "intent_analysis",
64            FrameKind::StepSummary => "step_summary",
65            FrameKind::CodeChange => "code_change",
66            FrameKind::SystemState => "system_state",
67            FrameKind::ErrorRecovery => "error_recovery",
68            FrameKind::Checkpoint => "checkpoint",
69            FrameKind::ToolCall => "tool_call",
70            FrameKind::Other(value) => value.as_str(),
71        }
72    }
73}
74
75impl fmt::Display for FrameKind {
76    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
77        write!(f, "{}", self.as_str())
78    }
79}
80
81/// Immutable incremental context record.
82///
83/// A `ContextFrame` is append-only history, not a mutable slot in a
84/// buffer. Current visibility of frames is a Libra concern.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(deny_unknown_fields)]
87pub struct ContextFrame {
88    /// Common object header carrying the immutable object id, type,
89    /// creator, and timestamps.
90    #[serde(flatten)]
91    header: Header,
92    /// Optional intent revision that emitted or owns this frame.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    intent_id: Option<Uuid>,
95    /// Optional run that emitted or owns this frame.
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    run_id: Option<Uuid>,
98    /// Optional plan revision associated with this frame.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    plan_id: Option<Uuid>,
101    /// Optional stable logical plan-step id associated with this frame.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    step_id: Option<Uuid>,
104    /// Coarse semantic kind of context carried by this frame.
105    kind: FrameKind,
106    /// Human-readable short description of the context increment.
107    summary: String,
108    /// Optional structured payload with additional frame details.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    data: Option<serde_json::Value>,
111    /// Optional approximate token footprint for budgeting and retrieval.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    token_estimate: Option<u64>,
114}
115
116impl ContextFrame {
117    /// Create a new incremental context frame with the given kind and
118    /// summary.
119    pub fn new(
120        created_by: ActorRef,
121        kind: FrameKind,
122        summary: impl Into<String>,
123    ) -> Result<Self, String> {
124        Ok(Self {
125            header: Header::new(ObjectType::ContextFrame, created_by)?,
126            intent_id: None,
127            run_id: None,
128            plan_id: None,
129            step_id: None,
130            kind,
131            summary: summary.into(),
132            data: None,
133            token_estimate: None,
134        })
135    }
136
137    /// Return the immutable header for this context frame.
138    pub fn header(&self) -> &Header {
139        &self.header
140    }
141
142    /// Return the associated intent id, if present.
143    pub fn intent_id(&self) -> Option<Uuid> {
144        self.intent_id
145    }
146
147    /// Return the associated run id, if present.
148    pub fn run_id(&self) -> Option<Uuid> {
149        self.run_id
150    }
151
152    /// Return the associated plan id, if present.
153    pub fn plan_id(&self) -> Option<Uuid> {
154        self.plan_id
155    }
156
157    /// Return the associated stable plan-step id, if present.
158    pub fn step_id(&self) -> Option<Uuid> {
159        self.step_id
160    }
161
162    /// Return the semantic frame kind.
163    pub fn kind(&self) -> &FrameKind {
164        &self.kind
165    }
166
167    /// Return the short human-readable frame summary.
168    pub fn summary(&self) -> &str {
169        &self.summary
170    }
171
172    /// Return the structured payload, if present.
173    pub fn data(&self) -> Option<&serde_json::Value> {
174        self.data.as_ref()
175    }
176
177    /// Return the approximate token footprint, if present.
178    pub fn token_estimate(&self) -> Option<u64> {
179        self.token_estimate
180    }
181
182    /// Set or clear the associated intent id.
183    pub fn set_intent_id(&mut self, intent_id: Option<Uuid>) {
184        self.intent_id = intent_id;
185    }
186
187    /// Set or clear the associated run id.
188    pub fn set_run_id(&mut self, run_id: Option<Uuid>) {
189        self.run_id = run_id;
190    }
191
192    /// Set or clear the associated plan id.
193    pub fn set_plan_id(&mut self, plan_id: Option<Uuid>) {
194        self.plan_id = plan_id;
195    }
196
197    /// Set or clear the associated stable plan-step id.
198    pub fn set_step_id(&mut self, step_id: Option<Uuid>) {
199        self.step_id = step_id;
200    }
201
202    /// Set or clear the structured payload.
203    pub fn set_data(&mut self, data: Option<serde_json::Value>) {
204        self.data = data;
205    }
206
207    /// Set or clear the approximate token footprint.
208    pub fn set_token_estimate(&mut self, token_estimate: Option<u64>) {
209        self.token_estimate = token_estimate;
210    }
211}
212
213impl fmt::Display for ContextFrame {
214    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
215        write!(f, "ContextFrame: {}", self.header.object_id())
216    }
217}
218
219impl ObjectTrait for ContextFrame {
220    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
221    where
222        Self: Sized,
223    {
224        serde_json::from_slice(data).map_err(|e| GitError::InvalidContextFrameObject(e.to_string()))
225    }
226
227    fn get_type(&self) -> ObjectType {
228        ObjectType::ContextFrame
229    }
230
231    fn get_size(&self) -> usize {
232        match serde_json::to_vec(self) {
233            Ok(v) => v.len(),
234            Err(e) => {
235                tracing::warn!("failed to compute ContextFrame size: {}", e);
236                0
237            }
238        }
239    }
240
241    fn to_data(&self) -> Result<Vec<u8>, GitError> {
242        serde_json::to_vec(self).map_err(|e| GitError::InvalidContextFrameObject(e.to_string()))
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    // Coverage:
251    // - intent/run/plan/step association links
252    // - frame payload storage
253    // - token estimate capture
254
255    #[test]
256    fn test_context_frame_fields() {
257        let actor = ActorRef::agent("planner").expect("actor");
258        let mut frame =
259            ContextFrame::new(actor, FrameKind::StepSummary, "Updated API").expect("frame");
260        let intent_id = Uuid::from_u128(0x0f);
261        let run_id = Uuid::from_u128(0x10);
262        let plan_id = Uuid::from_u128(0x11);
263        let step_id = Uuid::from_u128(0x12);
264
265        frame.set_intent_id(Some(intent_id));
266        frame.set_run_id(Some(run_id));
267        frame.set_plan_id(Some(plan_id));
268        frame.set_step_id(Some(step_id));
269        frame.set_data(Some(serde_json::json!({"files": ["src/lib.rs"]})));
270        frame.set_token_estimate(Some(128));
271
272        assert_eq!(frame.intent_id(), Some(intent_id));
273        assert_eq!(frame.run_id(), Some(run_id));
274        assert_eq!(frame.plan_id(), Some(plan_id));
275        assert_eq!(frame.step_id(), Some(step_id));
276        assert_eq!(frame.kind(), &FrameKind::StepSummary);
277        assert_eq!(frame.token_estimate(), Some(128));
278    }
279}