car-server-core 0.47.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Session-scoped task state for the assistant (Parslee-ai/car#814).
//!
//! # Why
//!
//! On a multi-step task the model's only working memory for "what is left" was
//! the transcript — and compaction evicts the middle of the transcript. So a
//! subtask agreed at turn 3 could simply vanish by turn 20, and the trace shows
//! a plausible-looking turn with no sign that anything was dropped. The failure
//! reads as model error when it is harness error.
//!
//! A todo list is the cheapest fix and the most widely adopted one in the field
//! (LangChain Deep Agents `write_todos`, Microsoft Agent Framework
//! `TodoProvider`, Claude Agent SDK `TaskCreate`/`TaskUpdate`, OpenAI Codex plan
//! tools). It works because it is *live state*: it does not live in the
//! transcript, so compaction cannot evict it.
//!
//! # Scope
//!
//! Session-scoped and nothing more. Durable carry-over across runs belongs in
//! memgine, which already exists for it — duplicating that here would create a
//! second, weaker memory with its own persistence and merge questions.

use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

/// Where an item stands. `Dropped` is deliberately distinct from `Done`: a plan
/// that abandons a step and one that completes it are different histories, and
/// collapsing them would let a model quietly rewrite the first into the second.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TodoStatus {
    Open,
    Done,
    Dropped,
}

impl TodoStatus {
    fn marker(self) -> char {
        match self {
            TodoStatus::Open => ' ',
            TodoStatus::Done => 'x',
            TodoStatus::Dropped => '-',
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoItem {
    pub id: usize,
    pub text: String,
    pub status: TodoStatus,
}

/// The run's task list.
#[derive(Debug, Default)]
pub struct TodoList {
    items: Vec<TodoItem>,
}

/// Bound on how much of the list can reach the model, so a runaway plan cannot
/// become the largest thing in the context.
const MAX_ITEMS: usize = 40;
const MAX_TEXT: usize = 200;

impl TodoList {
    pub fn new() -> Self {
        Self::default()
    }

    /// Replace the whole list from a model-supplied array.
    ///
    /// Whole-list replacement rather than per-item mutation, deliberately: an
    /// update API needs the model to track ids correctly across turns, and a
    /// mis-addressed update silently marks the wrong item done. Rewriting the
    /// list is idempotent, has no id to get wrong, and is what LangChain's
    /// `write_todos` settled on for the same reason. Ids are assigned here so
    /// they are always dense and stable within a render.
    pub fn write(&mut self, items: &[Value]) -> Result<(), String> {
        if items.len() > MAX_ITEMS {
            return Err(format!(
                "too many items ({}); keep the plan under {MAX_ITEMS} — a list \
                 longer than that is a sign the task needs decomposing into \
                 sub-runs, not a longer checklist",
                items.len()
            ));
        }
        let mut parsed = Vec::with_capacity(items.len());
        for (i, raw) in items.iter().enumerate() {
            let text = raw
                .get("text")
                .and_then(Value::as_str)
                .ok_or_else(|| format!("item {i} has no `text`"))?
                .trim();
            if text.is_empty() {
                return Err(format!("item {i} has empty `text`"));
            }
            let status = match raw.get("status").and_then(Value::as_str) {
                None => TodoStatus::Open,
                Some("open") => TodoStatus::Open,
                Some("done") => TodoStatus::Done,
                Some("dropped") => TodoStatus::Dropped,
                Some(other) => {
                    return Err(format!(
                        "item {i} has unknown status '{other}'; use open, done, or dropped"
                    ))
                }
            };
            parsed.push(TodoItem {
                id: i + 1,
                text: super::value_store::clip_str(text, MAX_TEXT),
                status,
            });
        }
        self.items = parsed;
        Ok(())
    }

    pub fn items(&self) -> &[TodoItem] {
        &self.items
    }

    pub fn is_empty(&self) -> bool {
        self.items.is_empty()
    }

    /// Compact status line plus the open items — what the model needs to pick up
    /// where it left off, and nothing else.
    ///
    /// Completed items are counted but not listed: they are the part the model
    /// does *not* need to act on, and listing them grows the render without
    /// changing any decision. Dropped items are counted separately so an
    /// abandoned step cannot silently read as progress.
    pub fn render(&self) -> Option<String> {
        if self.items.is_empty() {
            return None;
        }
        let done = self
            .items
            .iter()
            .filter(|i| i.status == TodoStatus::Done)
            .count();
        let dropped = self
            .items
            .iter()
            .filter(|i| i.status == TodoStatus::Dropped)
            .count();
        let total = self.items.len();
        let mut out = format!("todo: {done}/{total} done");
        if dropped > 0 {
            out.push_str(&format!(", {dropped} dropped"));
        }
        let open: Vec<&TodoItem> = self
            .items
            .iter()
            .filter(|i| i.status == TodoStatus::Open)
            .collect();
        if open.is_empty() {
            out.push_str(" — nothing open");
        } else {
            for item in open {
                out.push_str(&format!(
                    "\n  [{}] {} {}",
                    item.status.marker(),
                    item.id,
                    item.text
                ));
            }
        }
        Some(out)
    }
}

/// The model-facing definition.
pub fn tool_def() -> Value {
    json!({
        "name": "todo_write",
        "description": "Record or update the plan for this task as a checklist. \
                        Write the WHOLE list each time — it replaces the previous \
                        one. Use it on any task with more than a couple of steps: \
                        the list is live state, so unlike the transcript it \
                        survives history compaction, and it is how you know what \
                        is left after earlier turns are dropped. Returns the \
                        current status.",
        "parameters": {
            "type": "object",
            "properties": {
                "items": {
                    "type": "array",
                    "description": "The complete checklist, in order.",
                    "items": {
                        "type": "object",
                        "properties": {
                            "text": { "type": "string", "description": "What the step is." },
                            "status": {
                                "type": "string",
                                "enum": ["open", "done", "dropped"],
                                "description": "Defaults to open. Use 'dropped' for a step \
                                                deliberately abandoned — it is not the same \
                                                as done."
                            }
                        },
                        "required": ["text"]
                    }
                }
            },
            "required": ["items"]
        }
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn items(specs: &[(&str, &str)]) -> Vec<Value> {
        specs
            .iter()
            .map(|(text, status)| json!({"text": text, "status": status}))
            .collect()
    }

    #[test]
    fn render_counts_progress_and_lists_only_what_is_open() {
        let mut list = TodoList::new();
        list.write(&items(&[
            ("read the spec", "done"),
            ("write the parser", "done"),
            ("wire the CLI", "open"),
            ("benchmark it", "open"),
        ]))
        .unwrap();

        let render = list.render().expect("a non-empty list renders");
        assert!(render.starts_with("todo: 2/4 done"), "{render}");
        assert!(
            render.contains("wire the CLI"),
            "open items listed: {render}"
        );
        assert!(
            !render.contains("read the spec"),
            "completed items are counted, not listed — they change no decision: {render}"
        );
    }

    /// A dropped step must not read as progress. `2/4 done` when one was
    /// abandoned would let the model believe it finished something it gave up on.
    #[test]
    fn dropped_is_reported_separately_from_done() {
        let mut list = TodoList::new();
        list.write(&items(&[
            ("try approach A", "dropped"),
            ("try approach B", "done"),
            ("ship it", "open"),
        ]))
        .unwrap();

        let render = list.render().unwrap();
        assert!(render.contains("1/3 done"), "{render}");
        assert!(render.contains("1 dropped"), "{render}");
    }

    #[test]
    fn writing_replaces_rather_than_appends() {
        let mut list = TodoList::new();
        list.write(&items(&[("first plan", "open")])).unwrap();
        list.write(&items(&[("second plan", "open")])).unwrap();
        assert_eq!(list.items().len(), 1);
        assert_eq!(list.items()[0].text, "second plan");
        // Ids stay dense across a rewrite, so a render is always 1..=n.
        assert_eq!(list.items()[0].id, 1);
    }

    #[test]
    fn an_empty_list_renders_nothing_rather_than_an_empty_header() {
        assert_eq!(TodoList::new().render(), None);
    }

    #[test]
    fn all_done_says_so_instead_of_listing_nothing() {
        let mut list = TodoList::new();
        list.write(&items(&[("a", "done"), ("b", "done")])).unwrap();
        let render = list.render().unwrap();
        assert!(render.contains("2/2 done"), "{render}");
        assert!(render.contains("nothing open"), "{render}");
    }

    /// Bad input is rejected with a message the model can act on, rather than
    /// silently coerced — a mis-parsed plan is worse than no plan.
    #[test]
    fn malformed_items_are_rejected_with_actionable_errors() {
        let mut list = TodoList::new();
        assert!(list
            .write(&[json!({"status": "open"})])
            .unwrap_err()
            .contains("no `text`"));
        assert!(list
            .write(&[json!({"text": "  "})])
            .unwrap_err()
            .contains("empty `text`"));
        let err = list
            .write(&[json!({"text": "x", "status": "in_progress"})])
            .unwrap_err();
        assert!(err.contains("unknown status 'in_progress'"), "{err}");
        assert!(
            err.contains("open, done, or dropped"),
            "the error must name the valid values: {err}"
        );
        // A rejected write leaves the previous list intact.
        assert!(list.is_empty());
    }

    #[test]
    fn the_list_is_bounded() {
        let mut list = TodoList::new();
        let many: Vec<Value> = (0..MAX_ITEMS + 1)
            .map(|i| json!({"text": i.to_string()}))
            .collect();
        assert!(list.write(&many).unwrap_err().contains("too many items"));

        // Long text is clipped rather than rejected — the step is still useful.
        list.write(&[json!({"text": "y".repeat(MAX_TEXT + 500)})])
            .unwrap();
        assert!(list.items()[0].text.len() <= MAX_TEXT + 4);
    }
}