Skip to main content

kcode_kennedy_subagent_context/
lib.rs

1//! Box-free virtual Ktool state for one Kennedy subagent.
2
3#![forbid(unsafe_code)]
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use kcode_agent_runtime::StateUpdate;
8use kcode_dev_tools::{ManagedSourceKind, SourceSnapshot};
9use kcode_kweb_context::{Context as KwebContext, NodeDraft, ProjectionItem, StagedCreate};
10
11const KWEB_PREFIX: &str = "kweb:";
12const MANAGED_PREFIX: &str = "managed:";
13
14/// Current state changes produced by one successful child operation.
15#[derive(Clone, Debug, Default, Eq, PartialEq)]
16pub struct StateChanges {
17    /// Stable replace-or-remove updates consumed by `kcode-agent-runtime`.
18    pub updates: Vec<StateUpdate>,
19}
20
21impl StateChanges {
22    /// Whether no current value changed.
23    pub fn is_empty(&self) -> bool {
24        self.updates.is_empty()
25    }
26
27    /// Complete changed values, in update order, for a result that displays them.
28    pub fn display_text(&self) -> String {
29        self.updates
30            .iter()
31            .filter_map(|update| update.text.as_deref())
32            .collect::<Vec<_>>()
33            .join("\n\n")
34    }
35
36    /// Keys for every complete current value returned by [`Self::display_text`].
37    pub fn displayed_state_keys(&self) -> Vec<String> {
38        self.updates
39            .iter()
40            .filter(|update| update.text.is_some())
41            .map(|update| update.key.clone())
42            .collect()
43    }
44}
45
46/// One applied managed-source snapshot and its stable virtual identity.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct SourceState {
49    /// Stable identity for this source kind and project.
50    pub key: String,
51    /// Complete current state rendered after historical tool results.
52    pub text: String,
53    /// Replacement update when the state changed.
54    pub update: Option<StateUpdate>,
55}
56
57/// Ephemeral current state owned exclusively by one subagent run.
58#[derive(Clone, Debug)]
59pub struct Context {
60    kweb: KwebContext,
61    states: BTreeMap<String, String>,
62    kweb_keys: BTreeSet<String>,
63    staged_node_ids: BTreeSet<String>,
64}
65
66impl Context {
67    /// Creates an empty child context with no automatically projected nodes.
68    pub fn new(root_node_ids: Vec<String>, load_fixed_connections: bool) -> anyhow::Result<Self> {
69        Ok(Self {
70            kweb: KwebContext::with_fixed_connections(root_node_ids, load_fixed_connections)
71                .map_err(anyhow::Error::new)?,
72            states: BTreeMap::new(),
73            kweb_keys: BTreeSet::new(),
74            staged_node_ids: BTreeSet::new(),
75        })
76    }
77
78    /// The independent Kweb state used by child `LoadNodes` and Kmap tools.
79    pub fn kweb(&self) -> &KwebContext {
80        &self.kweb
81    }
82
83    /// Mutable access for an atomic child Kweb load.
84    pub fn kweb_mut(&mut self) -> &mut KwebContext {
85        &mut self.kweb
86    }
87
88    /// Makes explicitly referenced pending nodes visible to this child.
89    pub fn include_staged_nodes(&mut self, ids: impl IntoIterator<Item = String>) {
90        self.staged_node_ids.extend(ids);
91    }
92
93    /// Reconciles the child's complete Kweb state without touching Chatend.
94    pub fn reconcile_kweb(
95        &mut self,
96        updates: &BTreeMap<String, NodeDraft>,
97        creates: &[StagedCreate],
98    ) -> anyhow::Result<StateChanges> {
99        let visible_updates = updates
100            .iter()
101            .filter(|(id, _)| self.kweb.contains_full_node(id))
102            .map(|(id, update)| (id.clone(), update.clone()))
103            .collect();
104        let visible_creates = creates
105            .iter()
106            .filter(|create| self.staged_node_ids.contains(&create.pending_id))
107            .cloned()
108            .collect::<Vec<_>>();
109        let projected = self
110            .kweb
111            .projection(&visible_updates, &visible_creates)
112            .map_err(anyhow::Error::new)?;
113        let next_keys = projected
114            .iter()
115            .map(|item| kweb_key(&item.key))
116            .collect::<BTreeSet<_>>();
117        let mut changes = StateChanges::default();
118
119        for key in self.kweb_keys.difference(&next_keys) {
120            self.states.remove(key);
121            changes.updates.push(StateUpdate {
122                key: key.clone(),
123                text: None,
124            });
125        }
126        for item in projected {
127            self.apply_kweb_item(item, &mut changes);
128        }
129        self.kweb_keys = next_keys;
130        Ok(changes)
131    }
132
133    /// Installs one current managed-source value in the child context.
134    pub fn apply_source_snapshot(&mut self, snapshot: SourceSnapshot) -> SourceState {
135        let state = source_state(&snapshot);
136        let update = (self.states.get(&state.key) != Some(&state.text)).then(|| StateUpdate {
137            key: state.key.clone(),
138            text: Some(state.text.clone()),
139        });
140        self.states.insert(state.key.clone(), state.text.clone());
141        SourceState { update, ..state }
142    }
143
144    /// Renders a prospective managed-source value without applying it.
145    pub fn source_state(&self, snapshot: &SourceSnapshot) -> SourceState {
146        let mut state = source_state(snapshot);
147        state.update = (self.states.get(&state.key) != Some(&state.text)).then(|| StateUpdate {
148            key: state.key.clone(),
149            text: Some(state.text.clone()),
150        });
151        state
152    }
153
154    /// Whether this exact source kind and project is open in the child.
155    pub fn source_is_open(&self, kind: ManagedSourceKind, name: &str) -> bool {
156        self.states.contains_key(&managed_key(kind, name))
157    }
158
159    fn apply_kweb_item(&mut self, item: ProjectionItem, changes: &mut StateChanges) {
160        let key = kweb_key(&item.key);
161        let text = format!("Current {}:\n{}", item.name, item.text);
162        if self.states.get(&key) != Some(&text) {
163            self.states.insert(key.clone(), text.clone());
164            changes.updates.push(StateUpdate {
165                key,
166                text: Some(text),
167            });
168        }
169    }
170}
171
172fn source_state(snapshot: &SourceSnapshot) -> SourceState {
173    SourceState {
174        key: managed_key(snapshot.kind, &snapshot.name),
175        text: format!(
176            "Current Managed {} {}:\n{}",
177            snapshot.kind.label(),
178            snapshot.name,
179            snapshot.text
180        ),
181        update: None,
182    }
183}
184
185fn kweb_key(logical: &str) -> String {
186    format!("{KWEB_PREFIX}{logical}")
187}
188
189fn managed_key(kind: ManagedSourceKind, name: &str) -> String {
190    let kind = match kind {
191        ManagedSourceKind::RustLibrary => "rust-library",
192        ManagedSourceKind::WebLibrary => "web-library",
193        ManagedSourceKind::RustBinary => "rust-binary",
194    };
195    format!("{MANAGED_PREFIX}{kind}:{name}")
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use kcode_kweb_context::Node;
202
203    fn node(description: &str) -> Node {
204        Node {
205            id: "AAAAAAAE".into(),
206            short_name: "Root".into(),
207            short_description: "Summary".into(),
208            long_description: description.into(),
209            owner: "self".into(),
210            fixed_connections: Vec::new(),
211            recent_connections: Vec::new(),
212            objects: Vec::new(),
213            last_modified_by: "test".into(),
214            last_modified_at: None,
215        }
216    }
217
218    #[test]
219    fn kweb_changes_reuse_stable_keys_without_boxes() {
220        let mut context = Context::new(vec!["AAAAAAAE".into()], false).unwrap();
221        context
222            .kweb_mut()
223            .apply_load(node("old"), Vec::new())
224            .unwrap();
225        let first = context.reconcile_kweb(&BTreeMap::new(), &[]).unwrap();
226        assert_eq!(first.updates[0].key, "kweb:AAAAAAAE");
227        assert!(first.display_text().contains("old"));
228
229        context.kweb_mut().refresh([node("new")]).unwrap();
230        let second = context.reconcile_kweb(&BTreeMap::new(), &[]).unwrap();
231        assert_eq!(second.updates.len(), 1);
232        assert_eq!(second.updates[0].key, "kweb:AAAAAAAE");
233        assert!(second.updates[0].text.as_deref().unwrap().contains("new"));
234    }
235
236    #[test]
237    fn managed_sources_are_isolated_by_kind_and_name() {
238        let mut context = Context::new(vec!["AAAAAAAE".into()], false).unwrap();
239        let snapshot = SourceSnapshot {
240            kind: ManagedSourceKind::RustLibrary,
241            name: "example".into(),
242            text: "source".into(),
243        };
244        let applied = context.apply_source_snapshot(snapshot.clone());
245        assert_eq!(applied.key, "managed:rust-library:example");
246        assert!(applied.update.is_some());
247        assert!(context.source_is_open(snapshot.kind, &snapshot.name));
248        assert!(context.apply_source_snapshot(snapshot).update.is_none());
249        assert!(!context.source_is_open(ManagedSourceKind::WebLibrary, "example"));
250    }
251
252    #[test]
253    fn kweb_projection_hides_unloaded_parent_plan_state() {
254        let mut context = Context::new(vec!["AAAAAAAE".into()], false).unwrap();
255        context
256            .kweb_mut()
257            .apply_load(node("loaded"), Vec::new())
258            .unwrap();
259        let unrelated_id = "AAAAAAAI".to_owned();
260        let updates = BTreeMap::from([(
261            unrelated_id,
262            NodeDraft {
263                short_name: "Hidden".into(),
264                short_description: "Hidden".into(),
265                long_description: "unrelated parent update".into(),
266                owner: "self".into(),
267                fixed_connections: Vec::new(),
268                recent_connections: Vec::new(),
269                objects: Vec::new(),
270            },
271        )]);
272        let hidden = StagedCreate {
273            pending_id: "pending:hidden".into(),
274            data: updates.values().next().unwrap().clone(),
275        };
276
277        let changes = context
278            .reconcile_kweb(&updates, std::slice::from_ref(&hidden))
279            .unwrap();
280        assert!(!changes.display_text().contains("unrelated parent update"));
281        assert!(!changes.display_text().contains("pending:hidden"));
282
283        context.include_staged_nodes([hidden.pending_id.clone()]);
284        let changes = context.reconcile_kweb(&updates, &[hidden]).unwrap();
285        assert!(changes.display_text().contains("pending:hidden"));
286    }
287}