Watch
1
0
Fork
You've already forked souveraine
0
souveraine/src/core/tools/itinerary.rs
Fimeg 45d25b25a4 pin CI to rust 1.94, fix agents detail pane + settings test
agents: wire the rest of the detail widgets into the section children so
PRIMARY badge etc actually render; drop the HARDCODED debug text.
settings: test needs a provider in the map before navigating to Providers
cat.
clippy: pin CI to rust 1.94 per Cargo.toml rust-version; also apply what
cargo clippy --fix could auto-fix for 1.96 compat.
2026-06-19 12:02:54 -04:00

680 lines
23 KiB
Rust

//! Itinerary — the agent's route through the current work.
//!
//! An itinerary is an ordered sequence of stops. Each stop optionally
//! references a todo commitment by its ID. Stops that reference a todo
//! inherit the todo's nature, energy, and thread; stops without a
//! reference are ephemeral waypoints that leave no trace in the task
//! system.
//!
//! The itinerary is persisted to `system/dynamic/itinerary.md` in the
//! agent's memfs — the same volatile workspace that holds energy balance,
//! context pressure, and other running-state files. It is not a commitment
//! store; it is the *active face* of whatever the agent is doing right now.
//!
//! ## Agent actions
//!
//! - `set` — lay out a new route (replaces current)
//! - `advance` — mark current stop done, move to next
//! - `describe` — read back the current itinerary
//! - `clear` — dismiss the itinerary entirely
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value as JsonValue;
use std::path::{Path, PathBuf};
use super::defs::{Tool, ToolContext, ToolError, ToolOutput};
// ── Data types ────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum StopStatus {
#[serde(rename = "pending")]
Pending,
#[serde(rename = "current")]
Current,
#[serde(rename = "done")]
Done,
}
impl StopStatus {
pub fn is_live(&self) -> bool {
matches!(self, Self::Pending | Self::Current)
}
}
/// A single stop on the itinerary.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Stop {
/// Short label shown in the header strip.
pub name: String,
/// Optional longer description (for `describe` output).
pub description: Option<String>,
/// If set, this stop links back to a todo commitment by its id.
/// The tool reads the todo's nature/energy from the file and surfaces
/// them to the agent and UI.
pub todo_id: Option<String>,
pub status: StopStatus,
/// Derived from the referenced todo, if any. Populated at read time.
#[serde(skip)]
pub nature: Option<String>,
/// Derived from the referenced todo, if any.
#[serde(skip)]
pub energy: Option<String>,
}
/// The full itinerary, stored as YAML frontmatter in a markdown file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Itinerary {
/// A short title for the whole route.
pub title: String,
/// Ordered stops.
pub stops: Vec<Stop>,
/// Index of the current stop (0-based). Persisted so resume works.
pub current: usize,
}
impl Itinerary {
pub fn is_active(&self) -> bool {
self.stops.iter().any(|s| s.status.is_live())
}
/// Return a compact one-line representation of the route for the
/// description output and the header strip.
pub fn route_line(&self) -> String {
let mut parts: Vec<String> = Vec::new();
for stop in self.stops.iter() {
let glyph = match stop.status {
StopStatus::Done => "",
StopStatus::Current => "",
StopStatus::Pending => "",
};
let mut label = format!("{} {}", glyph, stop.name);
if let Some(ref e) = stop.energy {
let icon = match e.as_str() {
"generative" => "",
"consumptive" => "",
_ => "",
};
if let Some(ref n) = stop.nature {
let nature_glyph = match n.as_str() {
"desire" => "",
"investigation" => "",
"obligation" => "",
"maintenance" => "",
_ => "·",
};
label = format!("{} {} {}", label, nature_glyph, icon);
} else {
label = format!("{} {}", label, icon);
}
}
parts.push(label);
}
format!("{} · {}", self.title, parts.join(" "))
}
/// Summarise for the agent's describe action.
pub fn describe(&self) -> String {
let mut out = format!("## {}\n", self.title);
for (_i, stop) in self.stops.iter().enumerate() {
let marker = match stop.status {
StopStatus::Done => "",
StopStatus::Current => "",
StopStatus::Pending => "",
};
let nature = stop.nature.as_deref().unwrap_or("");
let energy = stop.energy.as_deref().unwrap_or("");
let todo_ref = stop
.todo_id
.as_ref()
.map(|id| format!(" (todo: `{}`)", id))
.unwrap_or_default();
let desc = stop
.description
.as_deref()
.map(|d| format!("{}", d))
.unwrap_or_default();
out.push_str(&format!(
"\n{marker} **{}**{desc}{todo_ref}",
stop.name
));
if !nature.is_empty() || !energy.is_empty() {
out.push_str(&format!(" [{}{}]", nature, energy));
}
}
out.push_str(&format!(
"\n\nStep {} of {}",
self.current + 1,
self.stops.len()
));
out
}
}
// ── Helpers ───────────────────────────────────────────────────────
fn ok(msg: impl Into<String>) -> Result<ToolOutput, ToolError> {
Ok(ToolOutput {
content: msg.into(),
is_error: false,
raw: None,
})
}
fn err(detail: &str) -> ToolError {
ToolError::invalid_input(detail)
}
/// Path to the itinerary file inside `system/dynamic/`.
fn itin_path(dynamic_dir: &Path) -> PathBuf {
dynamic_dir.join("itinerary.md")
}
/// Read the current itinerary from disk.
/// `dynamic_dir` = memory_root / system / dynamic
pub fn load(dynamic_dir: &Path) -> Option<Itinerary> {
let path = itin_path(dynamic_dir);
let content = std::fs::read_to_string(&path).ok()?;
// tasks/ lives at memory_root/tasks/ = dynamic_dir/../tasks/
let tasks_dir = dynamic_dir.parent().and_then(|p| {
let t = p.join("tasks");
if t.exists() { Some(t) } else { None }
});
let body = if let Some(rest) = content.strip_prefix("---\n") {
if let Some(end) = rest.find("\n---\n") {
&rest[..end]
} else {
return None;
}
} else {
return None;
};
let mut ity: Itinerary = serde_yaml::from_str(body).ok()?;
enrich_from_todos(&mut ity, tasks_dir.as_deref());
Some(ity)
}
/// Write the itinerary to `dynamic_dir/itinerary.md`.
fn save(dynamic_dir: &Path, ity: &Itinerary) -> Result<(), String> {
std::fs::create_dir_all(dynamic_dir)
.map_err(|e| format!("cannot create {}: {e}", dynamic_dir.display()))?;
let yaml = serde_yaml::to_string(&ity).map_err(|e| format!("serialize: {e}"))?;
let content = format!("---\n{}---\n\n# Itinerary\n{}", yaml, ity.title);
std::fs::write(dynamic_dir.join("itinerary.md"), &content)
.map_err(|e| format!("write: {e}"))?;
Ok(())
}
/// Populate `nature` and `energy` on each stop that references a todo.
fn enrich_from_todos(ity: &mut Itinerary, tasks_dir: Option<&Path>) {
let Some(dir) = tasks_dir else { return };
for stop in &mut ity.stops {
let Some(ref tid) = stop.todo_id else { continue };
let path = dir.join(format!("{}.md", tid));
let Ok(content) = std::fs::read_to_string(&path) else { continue };
let Some(body) = content.strip_prefix("---\n") else { continue };
let Some(end) = body.find("\n---\n") else { continue };
let fm = &body[..end];
for line in fm.lines() {
let Some((key, val)) = line.split_once(':') else { continue };
let key = key.trim();
let val = val.trim().trim_matches('"');
match key {
"nature" => stop.nature = Some(val.to_string()),
"energy" => stop.energy = Some(val.to_string()),
_ => {}
}
}
}
}
fn emit_event(ctx: &ToolContext, ity: &Itinerary) {
ctx.fire_event(crate::core::nervous::SensorEvent {
sensor_name: "itinerary".into(),
timestamp: chrono::Utc::now(),
event_type: "itinerary_changed".into(),
target: Some(ity.title.clone()),
urgency: 0.15,
payload: Some(serde_json::json!({
"current": ity.current,
"stops": ity.stops.len(),
})),
seed_id: None,
reply_to: None,
});
}
/// Complete a linked todo file on disk.
fn complete_todo(tasks_dir: &Path, todo_id: &str) {
let path = tasks_dir.join(format!("{todo_id}.md"));
let Ok(content) = std::fs::read_to_string(&path) else { return };
let updated = content
.replace("status: pending", "status: done")
.replace("status: in_progress", "status: done");
let now = chrono::Utc::now().to_rfc3339();
let updated = if !updated.contains("completed_at:") {
updated.replace(
"status: done",
&format!("status: done\ncompleted_at: {now}"),
)
} else {
updated
};
let _ = std::fs::write(&path, &updated);
}
// ── Tool implementation ───────────────────────────────────────────
pub struct ItineraryTool;
#[async_trait]
impl Tool for ItineraryTool {
fn name(&self) -> &str {
"itinerary"
}
fn description(&self) -> &str {
"I lay out the route ahead of me. An itinerary is a sequence of stops — the steps \
I plan to take through the current work. Each stop can be a free waypoint or can \
link back to a todo commitment; when it links to a todo, its nature and energy \
travel with it.\n\n\
The itinerary is not my task list — it's the *active face* of whatever I'm doing \
right now. It lives in the strip at the top of the conversation so we both know \
where I am.\n\n\
## Actions\n\
- `set` — lay out a new route. Takes a `title` and a list of `stops`. Pass \
`todo_id` to link a stop to an existing commitment.\n\
- `advance` — mark the current stop done and move to the next one. Optionally \
pass `todo_id` to auto-complete a linked todo; pass `phase` to set the next \
stop's phase marker.\n\
- `describe` — read back the whole route with status and details.\n\
- `clear` — dismiss the itinerary. Does not touch linked todos."
}
fn parameter_schema(&self) -> JsonValue {
serde_json::json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["set", "advance", "describe", "clear"]
},
"title": {
"type": "string",
"description": "Title for the whole route (required for `set`)."
},
"stops": {
"type": "array",
"description": "Ordered stops for `set`. Each stop is an object with `name`, \
optional `description`, and optional `todo_id`.",
"items": {
"type": "object",
"properties": {
"name": { "type": "string", "description": "Short label." },
"description": { "type": "string", "description": "Optional longer description." },
"todo_id": { "type": "string", "description": "Optional — link to an existing todo commitment." }
},
"required": ["name"]
}
},
"todo_id": {
"type": "string",
"description": "When advancing, optionally complete this linked todo."
},
"phase": {
"type": "string",
"description": "Phase marker for the next stop after advancing (e.g. '3/6')."
}
},
"required": ["action"]
})
}
async fn execute(&self, input: JsonValue, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let action = input
.get("action")
.and_then(|v| v.as_str())
.unwrap_or("describe");
let memory_root = ctx
.memory_root
.clone()
.ok_or_else(|| err("no memory root — I can't access the agent's memory"))?;
let dynamic_dir = memory_root.join("system").join("dynamic");
let tasks_dir = memory_root.join("tasks");
std::fs::create_dir_all(&dynamic_dir).map_err(|e| {
err(&format!("could not create system/dynamic/: {e}"))
})?;
match action {
"set" => cmd_set(&input, &dynamic_dir, &tasks_dir, ctx),
"advance" => cmd_advance(&input, &dynamic_dir, &tasks_dir, ctx),
"describe" => cmd_describe(&dynamic_dir),
"clear" => cmd_clear(&dynamic_dir, ctx),
other => Err(err(&format!("unknown action: {other}"))),
}
}
}
// ── Command implementations (free functions for testability) ─────
fn cmd_set(
input: &JsonValue,
dynamic_dir: &Path,
tasks_dir: &Path,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
let title = input
.get("title")
.and_then(|v| v.as_str())
.ok_or_else(|| err("title is required"))?;
let stops_raw = input
.get("stops")
.and_then(|v| v.as_array())
.ok_or_else(|| err("stops array is required"))?;
let stops: Vec<Stop> = stops_raw
.iter()
.map(|s| {
let name = s
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unnamed")
.to_string();
let description = s.get("description").and_then(|v| v.as_str()).map(String::from);
let todo_id = s.get("todo_id").and_then(|v| v.as_str()).map(String::from);
Stop {
name,
description,
todo_id,
status: StopStatus::Pending,
nature: None,
energy: None,
}
})
.collect();
if stops.is_empty() {
return Err(err("at least one stop is required"));
}
let mut ity = Itinerary {
title: title.to_string(),
current: 0,
stops,
};
// Mark the first stop as current.
if let Some(first) = ity.stops.first_mut() {
first.status = StopStatus::Current;
}
enrich_from_todos(&mut ity, Some(tasks_dir));
save(dynamic_dir, &ity).map_err(|e| err(&e))?;
emit_event(ctx, &ity);
let first = &ity.stops[0];
let mut line = format!(
"Route set: {}{} stops.\n{}",
ity.title,
ity.stops.len(),
first.name,
);
if let Some(ref desc) = first.description {
line.push_str(&format!("{desc}"));
}
if let Some(ref e) = first.energy {
line.push_str(&format!(" [{e}]"));
}
Ok(ToolOutput { content: line, is_error: false, raw: None })
}
fn cmd_advance(
input: &JsonValue,
dynamic_dir: &Path,
tasks_dir: &Path,
ctx: &ToolContext,
) -> Result<ToolOutput, ToolError> {
let mut ity = load(dynamic_dir).ok_or_else(|| err("no active itinerary — use `set` first"))?;
let current = ity.current;
if current >= ity.stops.len() {
return Err(err("all stops already done — use `set` for a new route"));
}
// Mark the current stop done and optionally complete its linked todo.
if let Some(stop) = ity.stops.get_mut(current) {
stop.status = StopStatus::Done;
// Complete the stop's own linked todo (if any).
if let Some(ref tid) = stop.todo_id {
complete_todo(tasks_dir, tid);
}
// Also complete a todo_id passed explicitly (may differ from stop's).
if let Some(tid) = input.get("todo_id").and_then(|v| v.as_str()) {
complete_todo(tasks_dir, tid);
}
}
let next = current + 1;
if next < ity.stops.len() {
if let Some(stop) = ity.stops.get_mut(next) {
stop.status = StopStatus::Current;
if let Some(p) = input.get("phase").and_then(|v| v.as_str()) {
stop.description = Some(format!("phase: {p}"));
}
}
ity.current = next;
} else {
// No more stops — itinerary complete.
ity.current = ity.stops.len();
}
enrich_from_todos(&mut ity, Some(tasks_dir));
save(dynamic_dir, &ity).map_err(|e| err(&e))?;
emit_event(ctx, &ity);
let mut line = if next < ity.stops.len() {
let s = &ity.stops[next];
format!(
"Advanced — now on step {} of {}.\n{}",
next + 1,
ity.stops.len(),
s.name,
)
} else {
format!("Route complete! All {} stops done.", ity.stops.len())
};
if next < ity.stops.len() {
if let Some(ref desc) = ity.stops[next].description {
line.push_str(&format!("{desc}"));
}
if let Some(ref e) = ity.stops[next].energy {
line.push_str(&format!(" [{e}]"));
}
}
Ok(ToolOutput { content: line, is_error: false, raw: None })
}
fn cmd_describe(dynamic_dir: &Path) -> Result<ToolOutput, ToolError> {
match load(dynamic_dir) {
Some(ity) => {
let mut out = ity.describe();
let linked: Vec<&Stop> = ity.stops.iter().filter(|s| s.todo_id.is_some()).collect();
if !linked.is_empty() {
out.push_str("\n\n---\nLinked commitments:\n");
for stop in &linked {
let n = stop.nature.as_deref().unwrap_or("");
let e = stop.energy.as_deref().unwrap_or("");
out.push_str(&format!(
"- `{}` {} · {n} / {e}\n",
stop.todo_id.as_deref().unwrap_or(""),
stop.name,
));
}
}
ok(out)
}
None => ok("No active itinerary. Use `itinerary set` to lay out a route."),
}
}
fn cmd_clear(dynamic_dir: &Path, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
let path = itin_path(dynamic_dir);
if path.exists() {
let _ = std::fs::remove_file(&path);
}
emit_event(ctx, &Itinerary {
title: String::new(),
stops: vec![],
current: 0,
});
ok("Itinerary cleared.")
}
// ── Tests ─────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn temp_dynamic() -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("souveraine-itinerary-test-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(dir.join("system").join("dynamic")).unwrap();
dir
}
#[test]
fn roundtrip_empty_route() {
let dynamic_dir = temp_dynamic();
let ity = Itinerary {
title: "Test".into(),
stops: vec![],
current: 0,
};
save(&dynamic_dir, &ity).unwrap();
let loaded = load(&dynamic_dir).unwrap();
assert_eq!(loaded.title, "Test");
assert!(loaded.stops.is_empty());
}
#[test]
fn roundtrip_with_stops() {
let dynamic_dir = temp_dynamic();
let ity = Itinerary {
title: "Port the turn model".into(),
stops: vec![
Stop {
name: "Design".into(),
description: Some("sketch the interface".into()),
todo_id: None,
status: StopStatus::Current,
nature: None,
energy: None,
},
Stop {
name: "Implement".into(),
description: None,
todo_id: None,
status: StopStatus::Pending,
nature: None,
energy: None,
},
],
current: 0,
};
save(&dynamic_dir, &ity).unwrap();
let loaded = load(&dynamic_dir).unwrap();
assert_eq!(loaded.title, "Port the turn model");
assert_eq!(loaded.stops.len(), 2);
assert_eq!(loaded.stops[0].status, StopStatus::Current);
assert_eq!(loaded.stops[1].status, StopStatus::Pending);
}
#[test]
fn advance_marks_done_and_moves() {
let dynamic_dir = temp_dynamic();
let mut ity = Itinerary {
title: "Build".into(),
stops: vec![
Stop {
name: "A".into(),
description: None,
todo_id: None,
status: StopStatus::Current,
nature: None,
energy: None,
},
Stop {
name: "B".into(),
description: None,
todo_id: None,
status: StopStatus::Pending,
nature: None,
energy: None,
},
],
current: 0,
};
save(&dynamic_dir, &ity).unwrap();
// Manually advance
ity.stops[0].status = StopStatus::Done;
ity.stops[1].status = StopStatus::Current;
ity.current = 1;
save(&dynamic_dir, &ity).unwrap();
let loaded = load(&dynamic_dir).unwrap();
assert_eq!(loaded.stops[0].status, StopStatus::Done);
assert_eq!(loaded.stops[1].status, StopStatus::Current);
assert_eq!(loaded.current, 1);
}
#[test]
fn is_active_checks_live() {
assert!(!StopStatus::Done.is_live());
assert!(StopStatus::Pending.is_live());
assert!(StopStatus::Current.is_live());
}
#[test]
fn describe_renders_markdown() {
let ity = Itinerary {
title: "Test route".into(),
current: 0,
stops: vec![
Stop {
name: "One".into(),
description: Some("first".into()),
todo_id: None,
status: StopStatus::Current,
nature: None,
energy: None,
},
],
};
let desc = ity.describe();
assert!(desc.contains("Test route"));
assert!(desc.contains(""));
assert!(desc.contains("One"));
}
}