Cache-safe request assembly: append-only turn context and declared cache semantics (#11022)

This commit is contained in:
filip
2026-08-06 20:08:49 -07:00
committed by GitHub
parent ca52cce628
commit 66051ec7d2
39 changed files with 1589 additions and 1231 deletions
+38 -12
View File
@@ -248,6 +248,20 @@ pub async fn handle_term_log(command: String) -> Result<()> {
Ok(())
}
fn shell_history_text(newest_first_tail: &[&Message]) -> Option<String> {
let history: Vec<String> = newest_first_tail
.iter()
.filter(|m| !m.is_turn_context())
.rev()
.map(|m| m.as_concat_text())
.collect();
if history.is_empty() {
None
} else {
Some(history.join("\n"))
}
}
pub async fn handle_term_run(prompt: Vec<String>) -> Result<()> {
let prompt = prompt.join(" ");
let session_id = std::env::var("AGENT_SESSION_ID").map_err(|_| {
@@ -291,20 +305,12 @@ pub async fn handle_term_run(prompt: Vec<String>) -> Result<()> {
}
}
let prompt_with_context = if user_messages_after_last_assistant.is_empty() {
prompt
} else {
let history = user_messages_after_last_assistant
.iter()
.rev() // back to chronological order
.map(|m| m.as_concat_text())
.collect::<Vec<_>>()
.join("\n");
format!(
let prompt_with_context = match shell_history_text(&user_messages_after_last_assistant) {
Some(history) => format!(
"<shell_history>\n{}\n</shell_history>\n\n{}",
history, prompt
)
),
None => prompt,
};
let config = SessionBuilderConfig {
@@ -407,4 +413,24 @@ mod tests {
assert!(!script.contains("command_not_found"));
}
#[test]
fn shell_history_skips_turn_context_events() {
use goose::conversation::message::MessageMetadata;
let older = Message::user().with_text("git status");
let block = Message::user()
.with_text("<turn-context>cwd /repo</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context());
let newer = Message::user().with_text("cargo build");
let newest_first_tail = vec![&newer, &block, &older];
assert_eq!(
shell_history_text(&newest_first_tail).unwrap(),
"git status\ncargo build"
);
let only_block = vec![&block];
assert_eq!(shell_history_text(&only_block), None);
}
}
+60 -21
View File
@@ -12,7 +12,7 @@ mod thinking;
use crate::session::task_execution_display::{
format_task_execution_notification, TASK_EXECUTION_NOTIFICATION_TYPE,
};
use goose::conversation::{fix_conversation, Conversation};
use goose::conversation::{fix_conversation, merge_consecutive_messages_for_request, Conversation};
use std::env;
use std::io::Write;
use std::str::FromStr;
@@ -69,8 +69,16 @@ const SHELL_STATUS_MAX_LINES: usize = 3;
const SHELL_STATUS_RESERVED_WIDTH: usize = 2;
fn planner_provider_messages(plan_messages: &Conversation) -> Conversation {
let projected_messages = plan_messages.agent_visible_messages();
fix_conversation(Conversation::new_unvalidated(projected_messages)).0
// The planner prompt has no turn-context instructions; drop the blocks.
let projected_messages: Vec<Message> = plan_messages
.agent_visible_messages()
.into_iter()
.filter(|message| !message.is_turn_context())
.collect();
let fixed = fix_conversation(Conversation::new_unvalidated(projected_messages)).0;
Conversation::new_unvalidated(merge_consecutive_messages_for_request(
fixed.messages().clone(),
))
}
#[derive(Serialize, Deserialize, Debug)]
@@ -1645,24 +1653,29 @@ impl CliSession {
&Message::assistant().with_text(interrupt_prompt),
self.debug,
);
} else if let Some(last_msg) = self.messages.last() {
if last_msg.role == rmcp::model::Role::User {
match last_msg.content.first() {
Some(MessageContent::ToolResponse(_)) => {
self.push_message(Message::assistant().with_text(interrupt_prompt));
output::render_message(
&Message::assistant().with_text(interrupt_prompt),
self.debug,
);
}
Some(_) => {
self.messages.pop();
let assistant_msg = Message::assistant().with_text(interrupt_prompt);
self.push_message(assistant_msg.clone());
output::render_message(&assistant_msg, self.debug);
}
None => {
// Empty message content — nothing to do, just continue gracefully
} else {
while self.messages.last().is_some_and(Message::is_turn_context) {
self.messages.pop();
}
if let Some(last_msg) = self.messages.last() {
if last_msg.role == rmcp::model::Role::User {
match last_msg.content.first() {
Some(MessageContent::ToolResponse(_)) => {
self.push_message(Message::assistant().with_text(interrupt_prompt));
output::render_message(
&Message::assistant().with_text(interrupt_prompt),
self.debug,
);
}
Some(_) => {
self.messages.pop();
let assistant_msg = Message::assistant().with_text(interrupt_prompt);
self.push_message(assistant_msg.clone());
output::render_message(&assistant_msg, self.debug);
}
None => {
// Empty message content — nothing to do, just continue gracefully
}
}
}
}
@@ -2646,6 +2659,32 @@ mod tests {
.contains("hidden separator"));
}
#[test]
fn planner_history_excludes_turn_context_events() {
use goose::conversation::message::MessageMetadata;
let history = Conversation::new_unvalidated([
Message::user().with_text("plan the refactor"),
Message::user()
.with_text("<turn-context>cwd /repo, todo: ship v2</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
Message::assistant().with_text("on it"),
]);
let provider_text = planner_provider_messages(&history)
.agent_visible_messages()
.iter()
.map(|message| message.as_concat_text())
.collect::<Vec<_>>()
.join("\n");
assert!(provider_text.contains("plan the refactor"));
assert!(
!provider_text.contains("turn-context"),
"the planner prompt has no turn-context instructions, so blocks must not reach it"
);
}
#[test]
fn test_format_elapsed_time_under_60_seconds() {
// Test sub-second duration