Skip to main content

git_internal/internal/object/
intent.rs

1//! AI Intent snapshot.
2//!
3//! `Intent` is the immutable entry point of the agent workflow. It
4//! captures one revision of the user's request plus the optional
5//! analyzed `IntentSpec`.
6//!
7//! # How to use this object
8//!
9//! - Create a root `Intent` when Libra accepts a new user request.
10//! - Create a new `Intent` revision when the request is refined,
11//!   branched, or merged; link earlier revisions through `parents`.
12//! - Fill `spec` before persistence if analysis has already produced a
13//!   structured request.
14//! - Freeze analysis-time context through `analysis_context_frames`
15//!   when `ContextFrame`s were used to derive the `IntentSpec`.
16//!
17//! # How it works with other objects
18//!
19//! - `Plan.intent` points back to the `Intent` that the plan belongs to.
20//! - `Task.intent` may point back to the originating `Intent`.
21//! - `analysis_context_frames` freezes the context used to derive the
22//!   stored `IntentSpec`.
23//! - `IntentEvent` records lifecycle facts such as analyzed /
24//!   completed / cancelled.
25//!
26//! # How Libra should call it
27//!
28//! Libra should persist a new `Intent` for every semantic revision of
29//! the request, then keep "current thread head", "selected plan", and
30//! other mutable session state in Libra projections rather than on the
31//! `Intent` object itself.
32
33use std::fmt;
34
35use serde::{Deserialize, Serialize};
36use uuid::Uuid;
37
38use crate::{
39    errors::GitError,
40    hash::ObjectHash,
41    internal::object::{
42        ObjectTrait,
43        types::{ActorRef, Header, ObjectType},
44    },
45};
46
47/// Structured request payload derived from the free-form prompt.
48///
49/// `IntentSpec` remains intentionally schema-agnostic at the storage
50/// layer. Libra can impose additional application-level conventions on
51/// top of the raw JSON payload.
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
53#[serde(deny_unknown_fields)]
54#[serde(transparent)]
55pub struct IntentSpec(pub serde_json::Value);
56
57impl From<String> for IntentSpec {
58    fn from(value: String) -> Self {
59        Self(serde_json::Value::String(value))
60    }
61}
62
63impl From<&str> for IntentSpec {
64    fn from(value: &str) -> Self {
65        Self::from(value.to_string())
66    }
67}
68
69/// Immutable request/spec revision.
70///
71/// One stored `Intent` answers "what request revision existed here?".
72/// It does not answer "what is the current thread head?" or "which plan
73/// is currently selected?" because those are Libra projection concerns.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75#[serde(deny_unknown_fields)]
76pub struct Intent {
77    /// Common object header carrying the immutable object id, type,
78    /// creator, and timestamps.
79    #[serde(flatten)]
80    header: Header,
81    /// Parent intent revisions that this revision directly derives from.
82    ///
83    /// Multiple parents allow merge-style intent history similar to a
84    /// commit DAG.
85    #[serde(default, skip_serializing_if = "Vec::is_empty")]
86    parents: Vec<Uuid>,
87    /// Original free-form user request captured for this revision.
88    prompt: String,
89    /// Structured interpretation of `prompt`, when Libra or an agent has
90    /// already produced one at persistence time.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    spec: Option<IntentSpec>,
93    /// Immutable context-frame snapshot used while deriving `spec`.
94    ///
95    /// This is distinct from `Plan.context_frames`: these frames belong
96    /// to the prompt-analysis / intent-spec phase rather than the
97    /// plan-generation phase.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    analysis_context_frames: Vec<Uuid>,
100}
101
102impl Intent {
103    /// Create a new root intent revision from a free-form user prompt.
104    pub fn new(created_by: ActorRef, prompt: impl Into<String>) -> Result<Self, String> {
105        Ok(Self {
106            header: Header::new(ObjectType::Intent, created_by)?,
107            parents: Vec::new(),
108            prompt: prompt.into(),
109            spec: None,
110            analysis_context_frames: Vec::new(),
111        })
112    }
113
114    /// Create a new intent revision from a single parent intent.
115    ///
116    /// This is the common helper for linear refinement.
117    pub fn new_revision_from(
118        created_by: ActorRef,
119        prompt: impl Into<String>,
120        parent: &Self,
121    ) -> Result<Self, String> {
122        Self::new_revision_chain(created_by, prompt, &[parent.header.object_id()])
123    }
124
125    /// Create a new intent revision from multiple parent intents.
126    ///
127    /// Use this when Libra merges several prior intent branches into a
128    /// new request/spec revision.
129    pub fn new_revision_chain(
130        created_by: ActorRef,
131        prompt: impl Into<String>,
132        parent_ids: &[Uuid],
133    ) -> Result<Self, String> {
134        let mut intent = Self::new(created_by, prompt)?;
135        for id in parent_ids {
136            intent.add_parent(*id);
137        }
138        Ok(intent)
139    }
140
141    /// Return the immutable header for this intent revision.
142    pub fn header(&self) -> &Header {
143        &self.header
144    }
145
146    /// Return the direct parent intent ids of this revision.
147    pub fn parents(&self) -> &[Uuid] {
148        &self.parents
149    }
150
151    /// Return the original user prompt stored on this revision.
152    pub fn prompt(&self) -> &str {
153        &self.prompt
154    }
155
156    /// Return the structured request payload, if one was stored.
157    pub fn spec(&self) -> Option<&IntentSpec> {
158        self.spec.as_ref()
159    }
160
161    /// Return the analysis-time context frame ids frozen onto this
162    /// revision.
163    pub fn analysis_context_frames(&self) -> &[Uuid] {
164        &self.analysis_context_frames
165    }
166
167    /// Add one parent link if it is not already present and is not self.
168    pub fn add_parent(&mut self, parent_id: Uuid) {
169        if parent_id == self.header.object_id() {
170            return;
171        }
172        if !self.parents.contains(&parent_id) {
173            self.parents.push(parent_id);
174        }
175    }
176
177    /// Replace the parent set for this in-memory revision before
178    /// persistence.
179    pub fn set_parents(&mut self, parents: Vec<Uuid>) {
180        self.parents = parents;
181    }
182
183    /// Set or clear the structured spec for this in-memory revision.
184    pub fn set_spec(&mut self, spec: Option<IntentSpec>) {
185        self.spec = spec;
186    }
187
188    /// Replace the analysis-time context frame set for this in-memory
189    /// revision before persistence.
190    pub fn set_analysis_context_frames(&mut self, analysis_context_frames: Vec<Uuid>) {
191        self.analysis_context_frames = analysis_context_frames;
192    }
193}
194
195impl fmt::Display for Intent {
196    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
197        write!(f, "Intent: {}", self.header.object_id())
198    }
199}
200
201impl ObjectTrait for Intent {
202    fn from_bytes(data: &[u8], _hash: ObjectHash) -> Result<Self, GitError>
203    where
204        Self: Sized,
205    {
206        serde_json::from_slice(data).map_err(|e| GitError::InvalidIntentObject(e.to_string()))
207    }
208
209    fn get_type(&self) -> ObjectType {
210        ObjectType::Intent
211    }
212
213    fn get_size(&self) -> usize {
214        match serde_json::to_vec(self) {
215            Ok(v) => v.len(),
216            Err(e) => {
217                tracing::warn!("failed to compute Intent size: {}", e);
218                0
219            }
220        }
221    }
222
223    fn to_data(&self) -> Result<Vec<u8>, GitError> {
224        serde_json::to_vec(self).map_err(|e| GitError::InvalidIntentObject(e.to_string()))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    // Coverage:
233    // - root intent construction defaults
234    // - revision graph creation for single-parent and multi-parent flows
235    // - structured spec assignment before persistence
236    // - frozen analysis-time context-frame references
237
238    #[test]
239    fn test_intent_creation() {
240        let actor = ActorRef::human("jackie").expect("actor");
241        let intent = Intent::new(actor, "Add pagination").expect("intent");
242
243        assert_eq!(intent.prompt(), "Add pagination");
244        assert!(intent.parents().is_empty());
245        assert!(intent.spec().is_none());
246        assert!(intent.analysis_context_frames().is_empty());
247    }
248
249    #[test]
250    fn test_intent_revision_graph() {
251        let actor = ActorRef::human("jackie").expect("actor");
252        let root = Intent::new(actor.clone(), "A").expect("intent");
253        let branch_a = Intent::new_revision_from(actor.clone(), "B", &root).expect("intent");
254        let branch_b = Intent::new_revision_chain(
255            actor,
256            "C",
257            &[root.header().object_id(), branch_a.header().object_id()],
258        )
259        .expect("intent");
260
261        assert_eq!(branch_a.parents(), &[root.header().object_id()]);
262        assert_eq!(
263            branch_b.parents(),
264            &[root.header().object_id(), branch_a.header().object_id()]
265        );
266    }
267
268    #[test]
269    fn test_spec_assignment() {
270        let actor = ActorRef::human("jackie").expect("actor");
271        let mut intent = Intent::new(actor, "A").expect("intent");
272        intent.set_spec(Some("structured spec".into()));
273        assert_eq!(intent.spec(), Some(&IntentSpec::from("structured spec")));
274    }
275
276    #[test]
277    fn test_analysis_context_frames() {
278        let actor = ActorRef::human("jackie").expect("actor");
279        let mut intent = Intent::new(actor, "A").expect("intent");
280        let frame_a = Uuid::from_u128(0x10);
281        let frame_b = Uuid::from_u128(0x11);
282
283        intent.set_analysis_context_frames(vec![frame_a, frame_b]);
284
285        assert_eq!(intent.analysis_context_frames(), &[frame_a, frame_b]);
286    }
287}