Skip to content
Closed
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
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ resolver = "2"

[workspace.package]
edition = "2021"
version = "1.12.0"
version = "1.12.1"
authors = ["Block <[email protected]>"]
license = "Apache-2.0"
repository = "https://github.com/block/goose"
Expand Down
10 changes: 1 addition & 9 deletions crates/goose-server/src/routes/schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ use axum::{
};
use serde::{Deserialize, Serialize};

use chrono::NaiveDateTime;

use crate::state::AppState;
use goose::scheduler::ScheduledJob;

Expand Down Expand Up @@ -82,12 +80,6 @@ pub struct SessionDisplayInfo {
accumulated_output_tokens: Option<i32>,
}

fn parse_session_name_to_iso(session_name: &str) -> String {
NaiveDateTime::parse_from_str(session_name, "%Y%m%d_%H%M%S")
.map(|dt| dt.and_utc().to_rfc3339())
.unwrap_or_else(|_| String::new()) // Fallback to empty string if parsing fails
}

#[utoipa::path(
post,
path = "/schedule/create",
Expand Down Expand Up @@ -326,7 +318,7 @@ async fn sessions_handler(
display_infos.push(SessionDisplayInfo {
id: session_name.clone(),
name: session.description,
created_at: parse_session_name_to_iso(&session_name),
created_at: session.created_at.to_rfc3339(),
working_dir: session.working_dir.to_string_lossy().into_owned(),
schedule_id: session.schedule_id,
message_count: session.message_count,
Expand Down
62 changes: 48 additions & 14 deletions crates/goose/src/context_mgmt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,55 @@ pub async fn compact_messages(

let messages = conversation.messages();

// Check if the most recent message is a user message
let (messages_to_compact, preserved_user_message) = if let Some(last_message) = messages.last()
{
if matches!(last_message.role, rmcp::model::Role::User) {
// Remove the last user message before compaction
(&messages[..messages.len() - 1], Some(last_message.clone()))
let has_text_only = |msg: &Message| {
let has_text = msg
.content
.iter()
.any(|c| matches!(c, MessageContent::Text(_)));
let has_tool_content = msg.content.iter().any(|c| {
matches!(
c,
MessageContent::ToolRequest(_) | MessageContent::ToolResponse(_)
)
});
has_text && !has_tool_content
};

// Helper function to extract text content from a message
let extract_text = |msg: &Message| -> Option<String> {
let text_parts: Vec<String> = msg
.content
.iter()
.filter_map(|c| {
if let MessageContent::Text(text) = c {
Some(text.text.clone())
} else {
None
}
})
.collect();

if text_parts.is_empty() {
None
} else {
Some(text_parts.join("\n"))
}
};

// Check if the most recent message is a user message with text content only
let (messages_to_compact, preserved_user_text) = if let Some(last_message) = messages.last() {
if matches!(last_message.role, rmcp::model::Role::User) && has_text_only(last_message) {
// Remove the last user message before compaction and preserve its text
(&messages[..messages.len() - 1], extract_text(last_message))
} else if preserve_last_user_message {
// Last message is not a user message, but we want to preserve the most recent user message
// Find the most recent user message and copy it (don't remove from history)
let most_recent_user_message = messages
// Last message is not a user message with text only, but we want to preserve the most recent user message with text only
// Find the most recent user message with text content only and extract its text
let preserved_text = messages
.iter()
.rev()
.find(|msg| matches!(msg.role, rmcp::model::Role::User))
.cloned();
(messages.as_slice(), most_recent_user_message)
.find(|msg| matches!(msg.role, rmcp::model::Role::User) && has_text_only(msg))
.and_then(extract_text);
(messages.as_slice(), preserved_text)
} else {
(messages.as_slice(), None)
}
Expand Down Expand Up @@ -125,8 +159,8 @@ Just continue the conversation naturally based on the summarized context"
final_token_counts.push(assistant_message_tokens);

// Add back the preserved user message if it exists
if let Some(user_message) = preserved_user_message {
final_messages.push(user_message);
if let Some(user_text) = preserved_user_text {
final_messages.push(Message::user().with_text(&user_text));
}

Ok((
Expand Down
57 changes: 41 additions & 16 deletions crates/goose/src/providers/formats/google.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,6 @@ pub fn get_accepted_keys(parent_key: Option<&str>) -> Vec<&str> {
"anyOf",
"allOf",
"type",
// "format", // Google's APIs don't support this well
"description",
"nullable",
"enum",
Expand All @@ -169,26 +168,37 @@ pub fn get_accepted_keys(parent_key: Option<&str>) -> Vec<&str> {
"items",
],
Some("items") => vec!["type", "properties", "items", "required"],
// This is the top-level schema.
_ => vec!["type", "properties", "required", "anyOf", "allOf"],
}
}

pub fn process_value(value: &Value, parent_key: Option<&str>) -> Value {
match value {
Value::Object(map) => process_map(map, parent_key),
Value::Array(arr) if parent_key == Some("type") => arr
.iter()
.find(|v| v.as_str() != Some("null"))
.cloned()
.unwrap_or_else(|| json!("string")),
_ => value.clone(),
}
}

/// Process a JSON map to filter out unsupported attributes, mirroring the logic
/// from the official Google Gemini CLI.
/// See: https://github.com/google-gemini/gemini-cli/blob/8a6509ffeba271a8e7ccb83066a9a31a5d72a647/packages/core/src/tools/tool-registry.ts#L356
pub fn process_map(map: &Map<String, Value>, parent_key: Option<&str>) -> Value {
let accepted_keys = get_accepted_keys(parent_key);

let filtered_map: Map<String, Value> = map
.iter()
.filter_map(|(key, value)| {
if !accepted_keys.contains(&key.as_str()) {
return None; // Skip if key is not accepted
return None;
}

match key.as_str() {
let processed_value = match key.as_str() {
"properties" => {
// Process each property within the properties object
if let Some(nested_map) = value.as_object() {
let processed_properties: Map<String, Value> = nested_map
.iter()
Expand All @@ -200,29 +210,44 @@ pub fn process_map(map: &Map<String, Value>, parent_key: Option<&str>) -> Value
}
})
.collect();
Some((key.clone(), Value::Object(processed_properties)))
Value::Object(processed_properties)
} else {
None
value.clone()
}
}
"items" => {
// If it's a nested structure, recurse if it's an object.
value.as_object().map(|nested_map| {
(key.clone(), process_map(nested_map, Some(key.as_str())))
})
if let Some(items_map) = value.as_object() {
process_map(items_map, Some("items"))
} else {
value.clone()
}
}
_ => {
// For other accepted keys, just clone the value.
Some((key.clone(), value.clone()))
"anyOf" | "allOf" => {
if let Some(arr) = value.as_array() {
let processed_arr: Vec<Value> = arr
.iter()
.map(|item| {
item.as_object().map_or_else(
|| item.clone(),
|obj| process_map(obj, parent_key),
)
})
.collect();
Value::Array(processed_arr)
} else {
value.clone()
}
}
}
_ => value.clone(),
};

Some((key.clone(), processed_value))
})
.collect();

Value::Object(filtered_map)
}

/// Convert Google's API response to internal Message format
pub fn response_to_message(response: Value) -> Result<Message> {
let mut content = Vec::new();
let binding = vec![];
Expand Down
2 changes: 1 addition & 1 deletion crates/goose/src/providers/openrouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[
"anthropic/claude-opus-4",
"anthropic/claude-3.7-sonnet",
"google/gemini-2.5-pro",
"google/gemini-flash-2.5",
"google/gemini-2.5-flash",
"deepseek/deepseek-r1-0528",
"qwen/qwen3-coder",
"moonshotai/kimi-k2",
Expand Down
Loading
Loading