Skip to content

Commit ef57f50

Browse files
Nest TODO State in session data (#4361)
Co-authored-by: Alex Hancock <alexhancock@block.xyz>
1 parent 7879445 commit ef57f50

File tree

11 files changed

+294
-45
lines changed

11 files changed

+294
-45
lines changed

crates/goose-server/src/openapi.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
445445
ModelInfo,
446446
SessionInfo,
447447
SessionMetadata,
448+
goose::session::ExtensionData,
448449
super::routes::schedule::CreateScheduleRequest,
449450
super::routes::schedule::UpdateScheduleRequest,
450451
super::routes::schedule::KillJobResponse,

crates/goose/src/agents/agent.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ use crate::providers::errors::ProviderError;
4141
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
4242
use crate::scheduler_trait::SchedulerTrait;
4343
use crate::session;
44+
use crate::session::extension_data::ExtensionState;
4445
use crate::tool_monitor::{ToolCall, ToolMonitor};
4546
use crate::utils::is_token_cancelled;
4647
use mcp_core::ToolResult;
@@ -494,7 +495,10 @@ impl Agent {
494495
let todo_content = if let Some(path) = session_file_path {
495496
session::storage::read_metadata(&path)
496497
.ok()
497-
.and_then(|m| m.todo_content)
498+
.and_then(|m| {
499+
session::TodoState::from_extension_data(&m.extension_data)
500+
.map(|state| state.content)
501+
})
498502
.unwrap_or_default()
499503
} else {
500504
String::new()
@@ -531,7 +535,11 @@ impl Agent {
531535
match session::storage::get_path(session_config.id.clone()) {
532536
Ok(path) => match session::storage::read_metadata(&path) {
533537
Ok(mut metadata) => {
534-
metadata.todo_content = Some(content);
538+
let todo_state = session::TodoState::new(content);
539+
todo_state
540+
.to_extension_data(&mut metadata.extension_data)
541+
.ok();
542+
535543
let path_clone = path.clone();
536544
let metadata_clone = metadata.clone();
537545
let update_result = tokio::task::spawn(async move {

crates/goose/src/context_mgmt/auto_compact.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ mod tests {
269269
accumulated_total_tokens: Some(100),
270270
accumulated_input_tokens: Some(50),
271271
accumulated_output_tokens: Some(50),
272-
todo_content: None,
272+
extension_data: crate::session::ExtensionData::new(),
273273
}
274274
}
275275

crates/goose/src/scheduler.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1298,7 +1298,7 @@ async fn run_scheduled_job_internal(
12981298
accumulated_total_tokens: None,
12991299
accumulated_input_tokens: None,
13001300
accumulated_output_tokens: None,
1301-
todo_content: None,
1301+
extension_data: crate::session::ExtensionData::new(),
13021302
};
13031303
if let Err(e_fb) = crate::session::storage::save_messages_with_metadata(
13041304
&session_file_path,
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Extension data management for sessions
2+
// Provides a simple way to store extension-specific data with versioned keys
3+
4+
use anyhow::Result;
5+
use serde::{Deserialize, Serialize};
6+
use serde_json::Value;
7+
use std::collections::HashMap;
8+
use utoipa::ToSchema;
9+
10+
/// Extension data containing all extension states
11+
/// Keys are in format "extension_name.version" (e.g., "todo.v0")
12+
#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)]
13+
pub struct ExtensionData {
14+
#[serde(flatten)]
15+
pub extension_states: HashMap<String, Value>,
16+
}
17+
18+
impl ExtensionData {
19+
/// Create a new empty ExtensionData
20+
pub fn new() -> Self {
21+
Self {
22+
extension_states: HashMap::new(),
23+
}
24+
}
25+
26+
/// Get extension state for a specific extension and version
27+
pub fn get_extension_state(&self, extension_name: &str, version: &str) -> Option<&Value> {
28+
let key = format!("{}.{}", extension_name, version);
29+
self.extension_states.get(&key)
30+
}
31+
32+
/// Set extension state for a specific extension and version
33+
pub fn set_extension_state(&mut self, extension_name: &str, version: &str, state: Value) {
34+
let key = format!("{}.{}", extension_name, version);
35+
self.extension_states.insert(key, state);
36+
}
37+
}
38+
39+
/// Helper trait for extension-specific state management
40+
pub trait ExtensionState: Sized + Serialize + for<'de> Deserialize<'de> {
41+
/// The name of the extension
42+
const EXTENSION_NAME: &'static str;
43+
44+
/// The version of the extension state format
45+
const VERSION: &'static str;
46+
47+
/// Convert from JSON value
48+
fn from_value(value: &Value) -> Result<Self> {
49+
serde_json::from_value(value.clone()).map_err(|e| {
50+
anyhow::anyhow!(
51+
"Failed to deserialize {} state: {}",
52+
Self::EXTENSION_NAME,
53+
e
54+
)
55+
})
56+
}
57+
58+
/// Convert to JSON value
59+
fn to_value(&self) -> Result<Value> {
60+
serde_json::to_value(self).map_err(|e| {
61+
anyhow::anyhow!("Failed to serialize {} state: {}", Self::EXTENSION_NAME, e)
62+
})
63+
}
64+
65+
/// Get state from extension data
66+
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
67+
extension_data
68+
.get_extension_state(Self::EXTENSION_NAME, Self::VERSION)
69+
.and_then(|v| Self::from_value(v).ok())
70+
}
71+
72+
/// Save state to extension data
73+
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<()> {
74+
let value = self.to_value()?;
75+
extension_data.set_extension_state(Self::EXTENSION_NAME, Self::VERSION, value);
76+
Ok(())
77+
}
78+
}
79+
80+
/// TODO extension state implementation
81+
#[derive(Debug, Clone, Serialize, Deserialize)]
82+
pub struct TodoState {
83+
pub content: String,
84+
}
85+
86+
impl ExtensionState for TodoState {
87+
const EXTENSION_NAME: &'static str = "todo";
88+
const VERSION: &'static str = "v0";
89+
}
90+
91+
impl TodoState {
92+
/// Create a new TODO state
93+
pub fn new(content: String) -> Self {
94+
Self { content }
95+
}
96+
}
97+
98+
#[cfg(test)]
99+
mod tests {
100+
use super::*;
101+
use serde_json::json;
102+
103+
#[test]
104+
fn test_extension_data_basic_operations() {
105+
let mut extension_data = ExtensionData::new();
106+
107+
// Test setting and getting extension state
108+
let todo_state = json!({"content": "- Task 1\n- Task 2"});
109+
extension_data.set_extension_state("todo", "v0", todo_state.clone());
110+
111+
assert_eq!(
112+
extension_data.get_extension_state("todo", "v0"),
113+
Some(&todo_state)
114+
);
115+
assert_eq!(extension_data.get_extension_state("todo", "v1"), None);
116+
}
117+
118+
#[test]
119+
fn test_multiple_extension_states() {
120+
let mut extension_data = ExtensionData::new();
121+
122+
// Add multiple extension states
123+
extension_data.set_extension_state("todo", "v0", json!("TODO content"));
124+
extension_data.set_extension_state("memory", "v1", json!({"items": ["item1", "item2"]}));
125+
extension_data.set_extension_state("config", "v2", json!({"setting": true}));
126+
127+
// Check all states exist
128+
assert_eq!(extension_data.extension_states.len(), 3);
129+
assert!(extension_data.get_extension_state("todo", "v0").is_some());
130+
assert!(extension_data.get_extension_state("memory", "v1").is_some());
131+
assert!(extension_data.get_extension_state("config", "v2").is_some());
132+
}
133+
134+
#[test]
135+
fn test_todo_state_trait() {
136+
let mut extension_data = ExtensionData::new();
137+
138+
// Create and save TODO state
139+
let todo = TodoState::new("- Task 1\n- Task 2".to_string());
140+
todo.to_extension_data(&mut extension_data).unwrap();
141+
142+
// Retrieve TODO state
143+
let retrieved = TodoState::from_extension_data(&extension_data);
144+
assert!(retrieved.is_some());
145+
assert_eq!(retrieved.unwrap().content, "- Task 1\n- Task 2");
146+
}
147+
148+
#[test]
149+
fn test_extension_data_serialization() {
150+
let mut extension_data = ExtensionData::new();
151+
extension_data.set_extension_state("todo", "v0", json!("TODO content"));
152+
extension_data.set_extension_state("memory", "v1", json!({"key": "value"}));
153+
154+
// Serialize to JSON
155+
let json = serde_json::to_value(&extension_data).unwrap();
156+
157+
// Check the structure
158+
assert!(json.is_object());
159+
assert_eq!(json.get("todo.v0"), Some(&json!("TODO content")));
160+
assert_eq!(json.get("memory.v1"), Some(&json!({"key": "value"})));
161+
162+
// Deserialize back
163+
let deserialized: ExtensionData = serde_json::from_value(json).unwrap();
164+
assert_eq!(
165+
deserialized.get_extension_state("todo", "v0"),
166+
Some(&json!("TODO content"))
167+
);
168+
assert_eq!(
169+
deserialized.get_extension_state("memory", "v1"),
170+
Some(&json!({"key": "value"}))
171+
);
172+
}
173+
}

crates/goose/src/session/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod extension_data;
12
pub mod info;
23
pub mod storage;
34

@@ -9,4 +10,5 @@ pub use storage::{
910
SessionMetadata,
1011
};
1112

13+
pub use extension_data::{ExtensionData, ExtensionState, TodoState};
1214
pub use info::{get_valid_sorted_sessions, SessionInfo};

crates/goose/src/session/storage.rs

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use crate::conversation::message::Message;
99
use crate::conversation::Conversation;
1010
use crate::providers::base::Provider;
11+
use crate::session::extension_data::ExtensionData;
1112
use crate::utils::safe_truncate;
1213
use anyhow::Result;
1314
use chrono::Local;
@@ -64,11 +65,13 @@ pub struct SessionMetadata {
6465
pub accumulated_input_tokens: Option<i32>,
6566
/// The number of output tokens used in the session. Accumulated across all messages.
6667
pub accumulated_output_tokens: Option<i32>,
67-
/// Session-scoped TODO list content
68-
pub todo_content: Option<String>,
68+
69+
/// Extension data containing extension states
70+
#[serde(default)]
71+
pub extension_data: ExtensionData,
6972
}
7073

71-
// Custom deserializer to handle old sessions without working_dir and todo_content
74+
// Custom deserializer to handle old sessions without working_dir
7275
impl<'de> Deserialize<'de> for SessionMetadata {
7376
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
7477
where
@@ -78,15 +81,16 @@ impl<'de> Deserialize<'de> for SessionMetadata {
7881
struct Helper {
7982
description: String,
8083
message_count: usize,
81-
schedule_id: Option<String>, // For backward compatibility
84+
schedule_id: Option<String>,
8285
total_tokens: Option<i32>,
8386
input_tokens: Option<i32>,
8487
output_tokens: Option<i32>,
8588
accumulated_total_tokens: Option<i32>,
8689
accumulated_input_tokens: Option<i32>,
8790
accumulated_output_tokens: Option<i32>,
8891
working_dir: Option<PathBuf>,
89-
todo_content: Option<String>, // For backward compatibility
92+
#[serde(default)]
93+
extension_data: ExtensionData,
9094
}
9195

9296
let helper = Helper::deserialize(deserializer)?;
@@ -108,7 +112,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
108112
accumulated_input_tokens: helper.accumulated_input_tokens,
109113
accumulated_output_tokens: helper.accumulated_output_tokens,
110114
working_dir,
111-
todo_content: helper.todo_content,
115+
extension_data: helper.extension_data,
112116
})
113117
}
114118
}
@@ -133,7 +137,7 @@ impl SessionMetadata {
133137
accumulated_total_tokens: None,
134138
accumulated_input_tokens: None,
135139
accumulated_output_tokens: None,
136-
todo_content: None,
140+
extension_data: ExtensionData::new(),
137141
}
138142
}
139143
}

crates/goose/tests/test_support.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
411411
accumulated_total_tokens: Some(100),
412412
accumulated_input_tokens: Some(50),
413413
accumulated_output_tokens: Some(50),
414-
todo_content: None,
414+
extension_data: Default::default(),
415415
}
416416
}

0 commit comments

Comments
 (0)