Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/goose-server/src/openapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ impl<'__s> ToSchema<'__s> for AnnotatedSchema {
ModelInfo,
SessionInfo,
SessionMetadata,
goose::session::ExtensionData,
super::routes::schedule::CreateScheduleRequest,
super::routes::schedule::UpdateScheduleRequest,
super::routes::schedule::KillJobResponse,
Expand Down
12 changes: 10 additions & 2 deletions crates/goose/src/agents/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ use crate::providers::errors::ProviderError;
use crate::recipe::{Author, Recipe, Response, Settings, SubRecipe};
use crate::scheduler_trait::SchedulerTrait;
use crate::session;
use crate::session::extension_data::ExtensionState;
use crate::tool_monitor::{ToolCall, ToolMonitor};
use crate::utils::is_token_cancelled;
use mcp_core::ToolResult;
Expand Down Expand Up @@ -494,7 +495,10 @@ impl Agent {
let todo_content = if let Some(path) = session_file_path {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should figure out how to lift this out of agent; finding the path to find the config to read the state for a specific tool is quite cumbersome; it also strikes me that the state here should be per extension, not per tool; we have a read_todo and a write_todo tool, but unfortunately they are not part of an actual extension. thoughts?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I'm with you that this is the messiest part. I was somewhat thinking of 'TODO' as an abstract tool but you're right with the current implementation it's really 2 different tools. So maybe re-implementing it as a pseudo extension with multiple tool calls (looks very similar in shape to how you'd make an mcp server) would help.

I think it would be really cool to in general support session scoped storage for tools, although not really part of the mcp spec so probably only relevant/helpful for the internal tools we build.

I guess trying to figure out what we want in the shortest term pre-next release.

Seems like options are:

  1. Roll back todo -> session state PR. This would leave the tool as disabled, as the original agent storage had the leaky state issue in desktop.
  2. Write todo state to a different file alongside session_state.txt file and just parse it as a string (we're touching the filesystem already). Then at least we don't pollute session metadata and when we change to a fancier session storage we can just stop reading this file.
  3. Continue from this direction and build out a v1 of session tool state.

I'm thinking (2).

session::storage::read_metadata(&path)
.ok()
.and_then(|m| m.todo_content)
.and_then(|m| {
session::TodoState::from_extension_data(&m.extension_data)
.map(|state| state.content)
})
.unwrap_or_default()
} else {
String::new()
Expand Down Expand Up @@ -531,7 +535,11 @@ impl Agent {
match session::storage::get_path(session_config.id.clone()) {
Ok(path) => match session::storage::read_metadata(&path) {
Ok(mut metadata) => {
metadata.todo_content = Some(content);
let todo_state = session::TodoState::new(content);
todo_state
.to_extension_data(&mut metadata.extension_data)
.ok();

let path_clone = path.clone();
let metadata_clone = metadata.clone();
let update_result = tokio::task::spawn(async move {
Expand Down
2 changes: 1 addition & 1 deletion crates/goose/src/context_mgmt/auto_compact.rs
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ mod tests {
accumulated_total_tokens: Some(100),
accumulated_input_tokens: Some(50),
accumulated_output_tokens: Some(50),
todo_content: None,
extension_data: crate::session::ExtensionData::new(),
}
}

Expand Down
2 changes: 1 addition & 1 deletion crates/goose/src/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1298,7 +1298,7 @@ async fn run_scheduled_job_internal(
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
todo_content: None,
extension_data: crate::session::ExtensionData::new(),
};
if let Err(e_fb) = crate::session::storage::save_messages_with_metadata(
&session_file_path,
Expand Down
173 changes: 173 additions & 0 deletions crates/goose/src/session/extension_data.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// Extension data management for sessions
// Provides a simple way to store extension-specific data with versioned keys

use anyhow::Result;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use utoipa::ToSchema;

/// Extension data containing all extension states
/// Keys are in format "extension_name.version" (e.g., "todo.v0")
#[derive(Debug, Clone, Serialize, Deserialize, Default, ToSchema)]
pub struct ExtensionData {
#[serde(flatten)]
pub extension_states: HashMap<String, Value>,
}

impl ExtensionData {
/// Create a new empty ExtensionData
pub fn new() -> Self {
Self {
extension_states: HashMap::new(),
}
}

/// Get extension state for a specific extension and version
pub fn get_extension_state(&self, extension_name: &str, version: &str) -> Option<&Value> {
let key = format!("{}.{}", extension_name, version);
self.extension_states.get(&key)
}

/// Set extension state for a specific extension and version
pub fn set_extension_state(&mut self, extension_name: &str, version: &str, state: Value) {
let key = format!("{}.{}", extension_name, version);
self.extension_states.insert(key, state);
}
}

/// Helper trait for extension-specific state management
pub trait ExtensionState: Sized + Serialize + for<'de> Deserialize<'de> {
/// The name of the extension
const EXTENSION_NAME: &'static str;

/// The version of the extension state format
const VERSION: &'static str;

/// Convert from JSON value
fn from_value(value: &Value) -> Result<Self> {
serde_json::from_value(value.clone()).map_err(|e| {
anyhow::anyhow!(
"Failed to deserialize {} state: {}",
Self::EXTENSION_NAME,
e
)
})
}

/// Convert to JSON value
fn to_value(&self) -> Result<Value> {
serde_json::to_value(self).map_err(|e| {
anyhow::anyhow!("Failed to serialize {} state: {}", Self::EXTENSION_NAME, e)
})
}

/// Get state from extension data
fn from_extension_data(extension_data: &ExtensionData) -> Option<Self> {
extension_data
.get_extension_state(Self::EXTENSION_NAME, Self::VERSION)
.and_then(|v| Self::from_value(v).ok())
}

/// Save state to extension data
fn to_extension_data(&self, extension_data: &mut ExtensionData) -> Result<()> {
let value = self.to_value()?;
extension_data.set_extension_state(Self::EXTENSION_NAME, Self::VERSION, value);
Ok(())
}
}

/// TODO extension state implementation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TodoState {
pub content: String,
}

impl ExtensionState for TodoState {
const EXTENSION_NAME: &'static str = "todo";
const VERSION: &'static str = "v0";
}

impl TodoState {
/// Create a new TODO state
pub fn new(content: String) -> Self {
Self { content }
}
}

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

#[test]
fn test_extension_data_basic_operations() {
let mut extension_data = ExtensionData::new();

// Test setting and getting extension state
let todo_state = json!({"content": "- Task 1\n- Task 2"});
extension_data.set_extension_state("todo", "v0", todo_state.clone());

assert_eq!(
extension_data.get_extension_state("todo", "v0"),
Some(&todo_state)
);
assert_eq!(extension_data.get_extension_state("todo", "v1"), None);
}

#[test]
fn test_multiple_extension_states() {
let mut extension_data = ExtensionData::new();

// Add multiple extension states
extension_data.set_extension_state("todo", "v0", json!("TODO content"));
extension_data.set_extension_state("memory", "v1", json!({"items": ["item1", "item2"]}));
extension_data.set_extension_state("config", "v2", json!({"setting": true}));

// Check all states exist
assert_eq!(extension_data.extension_states.len(), 3);
assert!(extension_data.get_extension_state("todo", "v0").is_some());
assert!(extension_data.get_extension_state("memory", "v1").is_some());
assert!(extension_data.get_extension_state("config", "v2").is_some());
}

#[test]
fn test_todo_state_trait() {
let mut extension_data = ExtensionData::new();

// Create and save TODO state
let todo = TodoState::new("- Task 1\n- Task 2".to_string());
todo.to_extension_data(&mut extension_data).unwrap();

// Retrieve TODO state
let retrieved = TodoState::from_extension_data(&extension_data);
assert!(retrieved.is_some());
assert_eq!(retrieved.unwrap().content, "- Task 1\n- Task 2");
}

#[test]
fn test_extension_data_serialization() {
let mut extension_data = ExtensionData::new();
extension_data.set_extension_state("todo", "v0", json!("TODO content"));
extension_data.set_extension_state("memory", "v1", json!({"key": "value"}));

// Serialize to JSON
let json = serde_json::to_value(&extension_data).unwrap();

// Check the structure
assert!(json.is_object());
assert_eq!(json.get("todo.v0"), Some(&json!("TODO content")));
assert_eq!(json.get("memory.v1"), Some(&json!({"key": "value"})));

// Deserialize back
let deserialized: ExtensionData = serde_json::from_value(json).unwrap();
assert_eq!(
deserialized.get_extension_state("todo", "v0"),
Some(&json!("TODO content"))
);
assert_eq!(
deserialized.get_extension_state("memory", "v1"),
Some(&json!({"key": "value"}))
);
}
}
2 changes: 2 additions & 0 deletions crates/goose/src/session/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod extension_data;
pub mod info;
pub mod storage;

Expand All @@ -9,4 +10,5 @@ pub use storage::{
SessionMetadata,
};

pub use extension_data::{ExtensionData, ExtensionState, TodoState};
pub use info::{get_valid_sorted_sessions, SessionInfo};
18 changes: 11 additions & 7 deletions crates/goose/src/session/storage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use crate::conversation::message::Message;
use crate::conversation::Conversation;
use crate::providers::base::Provider;
use crate::session::extension_data::ExtensionData;
use crate::utils::safe_truncate;
use anyhow::Result;
use chrono::Local;
Expand Down Expand Up @@ -64,11 +65,13 @@ pub struct SessionMetadata {
pub accumulated_input_tokens: Option<i32>,
/// The number of output tokens used in the session. Accumulated across all messages.
pub accumulated_output_tokens: Option<i32>,
/// Session-scoped TODO list content
pub todo_content: Option<String>,

/// Extension data containing extension states
#[serde(default)]
pub extension_data: ExtensionData,
}

// Custom deserializer to handle old sessions without working_dir and todo_content
// Custom deserializer to handle old sessions without working_dir
impl<'de> Deserialize<'de> for SessionMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
Expand All @@ -78,15 +81,16 @@ impl<'de> Deserialize<'de> for SessionMetadata {
struct Helper {
description: String,
message_count: usize,
schedule_id: Option<String>, // For backward compatibility
schedule_id: Option<String>,
total_tokens: Option<i32>,
input_tokens: Option<i32>,
output_tokens: Option<i32>,
accumulated_total_tokens: Option<i32>,
accumulated_input_tokens: Option<i32>,
accumulated_output_tokens: Option<i32>,
working_dir: Option<PathBuf>,
todo_content: Option<String>, // For backward compatibility
#[serde(default)]
extension_data: ExtensionData,
}

let helper = Helper::deserialize(deserializer)?;
Expand All @@ -108,7 +112,7 @@ impl<'de> Deserialize<'de> for SessionMetadata {
accumulated_input_tokens: helper.accumulated_input_tokens,
accumulated_output_tokens: helper.accumulated_output_tokens,
working_dir,
todo_content: helper.todo_content,
extension_data: helper.extension_data,
})
}
}
Expand All @@ -133,7 +137,7 @@ impl SessionMetadata {
accumulated_total_tokens: None,
accumulated_input_tokens: None,
accumulated_output_tokens: None,
todo_content: None,
extension_data: ExtensionData::new(),
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion crates/goose/tests/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,6 @@ pub fn create_test_session_metadata(message_count: usize, working_dir: &str) ->
accumulated_total_tokens: Some(100),
accumulated_input_tokens: Some(50),
accumulated_output_tokens: Some(50),
todo_content: None,
extension_data: Default::default(),
}
}
Loading
Loading