Cache-safe request assembly: append-only turn context and declared cache semantics (#11022)
Some checks failed
CI / Check Rust Code Format (push) Has been cancelled
CI / Build and Test TLS Backend (native-tls) (push) Has been cancelled
CI / Build and Test Rust Project (push) Has been cancelled
Canary / build-cli-linux (push) Has been cancelled
Canary / bundle-windows-cuda (push) Has been cancelled
Canary / Release (push) Has been cancelled
Create Minor Release PR / release (push) Has been cancelled
Unused Dependencies / machete (push) Has been cancelled
Canary / Prepare Version (push) Has been cancelled
Canary / Upload Install Script (push) Has been cancelled
Create Minor Release PR / check-version-bump-pr (push) Has been cancelled
CI / changes (push) Has been cancelled
CI / Build Rust Project on Windows (push) Has been cancelled
Live Provider Tests / check-fork (push) Has been cancelled
Publish Docker Image / docker (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Canary / bundle-macos-arm64 (push) Has been cancelled
Canary / bundle-macos-x64 (push) Has been cancelled
Canary / bundle-desktop-linux (push) Has been cancelled
Canary / bundle-windows (push) Has been cancelled
CI / Build and Test TLS Backend (rustls-tls) (push) Has been cancelled
CI / Check MSRV (push) Has been cancelled
CI / Lint Rust Code (push) Has been cancelled
CI / Check Generated Schemas are Up-to-Date (push) Has been cancelled
CI / Test and Lint Electron Desktop App (push) Has been cancelled
Live Provider Tests / changes (push) Has been cancelled
Live Provider Tests / Build Binary (push) Has been cancelled
Live Provider Tests / Smoke Tests (push) Has been cancelled
Live Provider Tests / Smoke Tests (Code Execution) (push) Has been cancelled
Live Provider Tests / Compaction Tests (push) Has been cancelled

This commit is contained in:
filip 2026-08-06 20:08:49 -07:00 committed by GitHub
parent ca52cce628
commit 66051ec7d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 1589 additions and 1231 deletions

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);
}
}

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

View file

@ -0,0 +1,203 @@
//! Prompt-cache semantics declared per (provider, model), instead of implied
//! by whichever format module a request flows through.
use serde_json::{json, Value};
use crate::formats::openai::is_openai_responses_model;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheSemantics {
/// Caller places markers; reuse needs an exact match of the marked bytes.
ExplicitBreakpoints { max_breakpoints: usize },
/// The longest matching stored prefix is reused implicitly.
ImplicitTolerant,
/// Only extends a prompt reproduced byte-for-byte from the start.
ImplicitStrict,
/// No known prompt cache.
Uncached,
}
impl CacheSemantics {
/// Unknown pairs default to `ImplicitStrict`, which is safe for every
/// cache.
pub fn for_model(provider_name: &str, model_name: &str) -> Self {
match provider_name {
"anthropic" | "minimax" | "zai" | "kimi_code" => {
CacheSemantics::ExplicitBreakpoints { max_breakpoints: 4 }
}
"aws_bedrock" | "databricks" | "gcp_vertex_ai" if model_name.contains("claude") => {
CacheSemantics::ExplicitBreakpoints { max_breakpoints: 4 }
}
"openrouter" | "litellm" if model_name.starts_with("anthropic/") => {
CacheSemantics::ExplicitBreakpoints { max_breakpoints: 4 }
}
"openai" | "azure_openai" | "github_copilot" => {
if is_openai_responses_model(model_name) {
CacheSemantics::ImplicitStrict
} else {
CacheSemantics::ImplicitTolerant
}
}
"moonshot" | "custom_deepseek" | "groq" | "together" | "fireworks-ai" | "mistral"
| "zhipu" | "alibaba" => CacheSemantics::ImplicitTolerant,
"snowflake" | "sagemaker_tgi" => CacheSemantics::Uncached,
_ => CacheSemantics::ImplicitStrict,
}
}
pub fn uses_explicit_breakpoints(self) -> bool {
matches!(self, CacheSemantics::ExplicitBreakpoints { .. })
}
}
/// Anthropic-dialect breakpoints for OpenAI-style chat payloads (OpenRouter,
/// LiteLLM, Databricks).
pub fn apply_chat_payload_breakpoints(payload: &mut Value) {
if let Some(messages) = payload
.get_mut("messages")
.and_then(|messages| messages.as_array_mut())
{
let mut user_count = 0;
for message in messages.iter_mut().rev() {
if message.get("role") != Some(&json!("user")) {
continue;
}
if mark_last_content_block(message) {
user_count += 1;
if user_count >= 2 {
break;
}
}
}
if let Some(system_message) = messages
.iter_mut()
.find(|message| message.get("role") == Some(&json!("system")))
{
mark_last_content_block(system_message);
}
}
if let Some(last_tool) = payload
.get_mut("tools")
.and_then(|tools| tools.as_array_mut())
.and_then(|tools| tools.last_mut())
{
if let Some(function) = last_tool.get_mut("function").and_then(Value::as_object_mut) {
function.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
}
}
}
fn mark_last_content_block(message: &mut Value) -> bool {
let Some(content) = message.get_mut("content") else {
return false;
};
if let Some(text) = content.as_str() {
*content = json!([{
"type": "text",
"text": text,
"cache_control": { "type": "ephemeral" }
}]);
return true;
}
if let Some(block) = content
.as_array_mut()
.and_then(|blocks| blocks.last_mut())
.and_then(Value::as_object_mut)
{
block.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn for_model_resolves_registered_identifiers_and_defaults_to_strict() {
let explicit = CacheSemantics::ExplicitBreakpoints { max_breakpoints: 4 };
for (provider, model, expected) in [
("anthropic", "claude-sonnet-4-5", explicit),
("kimi_code", "kimi-for-coding", explicit),
("aws_bedrock", "anthropic.claude-sonnet-4-5", explicit),
("minimax", "MiniMax-M2.5", explicit),
("openrouter", "anthropic/claude-sonnet-4-5", explicit),
(
"openrouter",
"openai/gpt-5.2",
CacheSemantics::ImplicitStrict,
),
("openai", "gpt-5.2", CacheSemantics::ImplicitStrict),
("openai", "gpt-4.1-mini", CacheSemantics::ImplicitTolerant),
("snowflake", "llama-3.3-70b", CacheSemantics::Uncached),
(
"some_new_vendor",
"some-model",
CacheSemantics::ImplicitStrict,
),
] {
assert_eq!(
CacheSemantics::for_model(provider, model),
expected,
"{provider}/{model}"
);
}
}
#[test]
fn breakpoints_cover_system_tools_and_last_two_user_messages() {
let mut payload = json!({
"model": "anthropic/claude-sonnet-4-5",
"messages": [
{"role": "system", "content": "be careful"},
{"role": "user", "content": "first"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "second"},
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "third"}
],
"tools": [
{"type": "function", "function": {"name": "a"}},
{"type": "function", "function": {"name": "b"}}
]
});
apply_chat_payload_breakpoints(&mut payload);
let messages = payload["messages"].as_array().unwrap();
assert_eq!(
messages[0]["content"][0]["cache_control"]["type"],
"ephemeral"
);
assert!(messages[1]["content"].is_string(), "only last two users");
assert_eq!(
messages[3]["content"][0]["cache_control"]["type"],
"ephemeral"
);
assert_eq!(
messages[5]["content"][0]["cache_control"]["type"],
"ephemeral"
);
let tools = payload["tools"].as_array().unwrap();
assert!(tools[0]["function"].get("cache_control").is_none());
assert_eq!(tools[1]["function"]["cache_control"]["type"], "ephemeral");
}
#[test]
fn array_content_user_messages_get_a_breakpoint() {
let mut payload = json!({
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "look at this"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,xyz"}}
]}
]
});
apply_chat_payload_breakpoints(&mut payload);
let blocks = payload["messages"][0]["content"].as_array().unwrap();
assert!(blocks[0].get("cache_control").is_none());
assert_eq!(blocks[1]["cache_control"]["type"], "ephemeral");
}
}

View file

@ -518,14 +518,33 @@ fn fix_tool_calling(mut messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
(messages, issues)
}
/// Never merges across visibility or turn-context boundaries, so the result
/// is safe to persist.
pub fn merge_consecutive_messages(messages: Vec<Message>) -> (Vec<Message>, Vec<String>) {
merge_consecutive(messages, false)
}
/// Merges regardless of visibility, for providers that require strict role
/// alternation. Never persist the result.
pub fn merge_consecutive_messages_for_request(messages: Vec<Message>) -> Vec<Message> {
merge_consecutive(messages, true).0
}
fn merge_consecutive(
messages: Vec<Message>,
across_visibility: bool,
) -> (Vec<Message>, Vec<String>) {
let mut issues = Vec::new();
let mut merged_messages: Vec<Message> = Vec::new();
for message in messages {
if let Some(last) = merged_messages.last_mut() {
let effective = effective_role(&message);
if effective_role(last) == effective {
if effective_role(last) == effective
&& (across_visibility
|| (last.metadata.user_visible == message.metadata.user_visible
&& last.metadata.turn_context == message.metadata.turn_context))
{
last.content.extend(message.content);
issues.push(format!("Merged consecutive {} messages", effective));
continue;
@ -613,12 +632,6 @@ pub const TURN_CONTEXT_TAG: &str = "turn-context";
pub const CURRENT_TIME_TAG: &str = "current-time";
pub const WORKING_DIRECTORY_TAG: &str = "working-directory";
pub fn is_turn_context_text(text: &str) -> bool {
text.starts_with(&format!("<{TURN_CONTEXT_TAG}>\n<{CURRENT_TIME_TAG}>"))
&& text.contains(&format!("</{CURRENT_TIME_TAG}>\n<{WORKING_DIRECTORY_TAG}>"))
&& text.trim_end().ends_with(&format!("</{TURN_CONTEXT_TAG}>"))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EffectiveRole {
User,
@ -713,7 +726,9 @@ pub fn debug_conversation_fix(
#[cfg(test)]
mod tests {
use crate::conversation::message::{InferenceMetadata, Message, MessageContentBlock};
use crate::conversation::message::{
InferenceMetadata, Message, MessageContentBlock, MessageMetadata,
};
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use rmcp::model::{CallToolRequestParams, Role};
use rmcp::object;
@ -846,6 +861,62 @@ mod tests {
assert_eq!(fixed[0].content.len(), 2);
}
#[test]
fn merge_does_not_cross_user_visibility() {
let visible = Message::user().with_text("what is in main.rs?");
let hidden = Message::user()
.with_text("<turn-context>frozen</turn-context>")
.with_visibility(false, true);
let (fixed, _) = fix_conversation(Conversation::new_unvalidated(vec![visible, hidden]));
assert_eq!(
fixed.messages().len(),
2,
"the persistable form keeps the agent-only event separate"
);
assert!(fixed.messages()[0].is_user_visible());
assert!(!fixed.messages()[1].is_user_visible());
let merged =
crate::conversation::merge_consecutive_messages_for_request(fixed.messages().clone());
assert_eq!(
merged.len(),
1,
"the request form merges to satisfy role alternation"
);
assert_eq!(merged[0].content.len(), 2);
}
#[test]
fn merge_does_not_erase_turn_context_marker() {
let preserved_prompt = Message::user()
.with_text("the preserved prompt")
.with_metadata(MessageMetadata::agent_only());
let carried_event = Message::user()
.with_text("<turn-context>frozen</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context());
let (fixed, _) = fix_conversation(Conversation::new_unvalidated(vec![
preserved_prompt,
carried_event,
]));
assert_eq!(
fixed.messages().len(),
2,
"the persistable form must not fold a turn-context event into the prompt"
);
assert!(!fixed.messages()[0].is_turn_context());
assert!(fixed.messages()[1].is_turn_context());
let merged =
crate::conversation::merge_consecutive_messages_for_request(fixed.messages().clone());
assert_eq!(
merged.len(),
1,
"the request form still merges to satisfy role alternation"
);
}
#[test]
fn test_orphaned_tools_and_empty_messages() {
use rmcp::model::ContentBlock;

View file

@ -742,6 +742,9 @@ pub struct MessageMetadata {
/// without matching user-visible text. Never sent to providers.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub steer: bool,
/// Whether this message is a per-turn context event appended by the agent.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub turn_context: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub usage: Option<Box<MessageUsage>>,
/// What an operation did to this message, keyed by operation name. Read back
@ -759,6 +762,7 @@ impl Default for MessageMetadata {
inference: None,
output_token_limit_reached: false,
steer: false,
turn_context: false,
usage: None,
operations: None,
}
@ -846,6 +850,11 @@ impl MessageMetadata {
self.steer = true;
self
}
pub fn with_turn_context(mut self) -> Self {
self.turn_context = true;
self
}
}
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
@ -1228,6 +1237,10 @@ impl Message {
pub fn is_agent_visible(&self) -> bool {
self.metadata.agent_visible
}
pub fn is_turn_context(&self) -> bool {
self.metadata.turn_context
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]

View file

@ -404,27 +404,16 @@ fn format_messages_with_options(
}));
}
// The volatile turn-context must sit after every cache breakpoint, or it invalidates the
// message-level cached prefix (Anthropic hashes tools -> system -> messages). Move it to the
// tail and place cache_control on the last non-turn-context block.
relocate_turn_context_to_tail(&mut anthropic_messages);
// The last two user messages extend the cached prefix each turn.
let mut user_count = 0;
for message in anthropic_messages.iter_mut().rev() {
if message.get(ROLE_FIELD) != Some(&json!(USER_ROLE)) {
continue;
}
let Some(content_array) = message
if let Some(block) = message
.get_mut(CONTENT_FIELD)
.and_then(|content| content.as_array_mut())
else {
continue;
};
let Some(target) = cache_control_target_index(content_array) else {
continue;
};
if let Some(block) = content_array
.get_mut(target)
.and_then(|content_array| content_array.last_mut())
.and_then(|b| b.as_object_mut())
{
block.insert(
@ -441,52 +430,6 @@ fn format_messages_with_options(
anthropic_messages
}
fn relocate_turn_context_to_tail(messages: &mut [Value]) {
let Some(last) = messages.len().checked_sub(1) else {
return;
};
let source = messages.iter().enumerate().rev().find_map(|(mi, m)| {
m.get(CONTENT_FIELD)
.and_then(|c| c.as_array())
.and_then(|a| a.iter().position(is_turn_context_block))
.map(|bi| (mi, bi))
});
let Some((mi, bi)) = source else {
return;
};
if mi != last
&& messages[mi]
.get(CONTENT_FIELD)
.and_then(|c| c.as_array())
.map_or(0, |a| a.len())
<= 1
{
return;
}
let block = messages[mi][CONTENT_FIELD]
.as_array_mut()
.unwrap()
.remove(bi);
messages[last][CONTENT_FIELD]
.as_array_mut()
.unwrap()
.push(block);
}
fn cache_control_target_index(content_array: &[Value]) -> Option<usize> {
content_array
.iter()
.rposition(|block| !is_turn_context_block(block))
}
fn is_turn_context_block(block: &Value) -> bool {
block.get(TYPE_FIELD).and_then(Value::as_str) == Some(TEXT_TYPE)
&& block
.get(TEXT_TYPE)
.and_then(Value::as_str)
.is_some_and(crate::conversation::is_turn_context_text)
}
fn anthropic_flavored_input_schema(input_schema: Arc<JsonObject>) -> Arc<JsonObject> {
if input_schema.is_empty() {
return Arc::new(json_object!({
@ -2543,29 +2486,12 @@ mod tests {
assert!(parts.tool_errors.is_empty());
}
/// Anthropic prefix caching only pays off when the bytes up to a cache
/// breakpoint are identical turn over turn. The per-turn turn-context block
/// (timestamp, turn budget, compaction state) changes on every call, so if
/// it ever lands inside a cached prefix every request becomes a cache write
/// instead of a read. These tests pin the property that keeps caching alive
/// so a future refactor of the formatter or the turn-context format can't
/// silently regress it.
mod cache_prefix_stability {
/// Placement shape only; cross-request prefix stability is covered by
/// `tests/prefix_invariance.rs`.
mod cache_breakpoint_placement {
use super::*;
use rmcp::model::CallToolResult;
/// A turn-context block whose shape matches what `is_turn_context_text`
/// recognizes, varying only the volatile fields.
fn turn_context(time: &str, turn_budget: &str) -> String {
format!(
"<turn-context>\n\
<current-time>{time}</current-time>\n\
<working-directory>/Users/me/code/goose</working-directory>\n\
<turn-budget>{turn_budget}</turn-budget>\n\
</turn-context>"
)
}
fn sample_tools() -> Vec<Tool> {
vec![
Tool::new(
@ -2587,11 +2513,21 @@ mod tests {
]
}
/// A realistic multi-turn conversation. `inject_moim` prepends the
/// turn-context block to the latest genuine user message, so it sits as
/// the first text block of the final user message here.
fn conversation(turn_context_block: &str) -> Vec<Message> {
vec![
fn breakpoints(messages: &[Value]) -> Vec<(usize, usize)> {
let mut found = Vec::new();
for (mi, message) in messages.iter().enumerate() {
for (bi, block) in message["content"].as_array().unwrap().iter().enumerate() {
if block.get(CACHE_CONTROL_FIELD).is_some() {
found.push((mi, bi));
}
}
}
found
}
#[test]
fn breakpoints_cover_tools_system_and_last_two_user_messages() {
let messages = vec![
Message::user().with_text("What does the main entrypoint do?"),
Message::assistant().with_tool_request(
"tool_1",
@ -2605,200 +2541,45 @@ mod tests {
])),
),
Message::assistant().with_text("It calls `run()`."),
Message::user()
.with_text(turn_context_block)
.with_text("Now add error handling to it."),
]
}
/// The (message index, block index) of the last block carrying a
/// `cache_control` marker, scanning in canonical order. This is the far
/// edge of the furthest cached prefix.
fn last_breakpoint(messages: &[Value]) -> Option<(usize, usize)> {
let mut found = None;
for (mi, message) in messages.iter().enumerate() {
for (bi, block) in message["content"].as_array().unwrap().iter().enumerate() {
if block.get(CACHE_CONTROL_FIELD).is_some() {
found = Some((mi, bi));
}
}
}
found
}
fn find_turn_context(messages: &[Value]) -> Option<(usize, usize)> {
messages.iter().enumerate().find_map(|(mi, message)| {
message["content"]
.as_array()
.unwrap()
.iter()
.position(is_turn_context_block)
.map(|bi| (mi, bi))
})
}
/// The exact bytes Anthropic hashes for its furthest cache breakpoint:
/// tools, then system, then messages truncated at the last
/// `cache_control` marker. Everything after that point is outside every
/// cached prefix and may change freely turn to turn.
fn cached_prefix(payload: &Value) -> String {
let messages = payload["messages"].as_array().unwrap();
let (last_mi, last_bi) = last_breakpoint(messages)
.expect("request must carry at least one cache_control breakpoint");
let prefix_messages: Vec<Value> = messages
.iter()
.take(last_mi + 1)
.enumerate()
.map(|(mi, message)| {
let mut message = message.clone();
if mi == last_mi {
message["content"]
.as_array_mut()
.unwrap()
.truncate(last_bi + 1);
}
message
})
.collect();
json!({
"tools": payload.get("tools"),
"system": payload.get("system"),
"messages": prefix_messages,
})
.to_string()
}
/// The production tool-loop case: `inject_moim` prepends turn-context to
/// the latest *genuine* user message, but the request then ends with a
/// later `tool_result` message. The block must be relocated *across*
/// messages to land after the trailing breakpoint, not merely reordered
/// within its own message.
fn tool_loop_conversation(turn_context_block: &str) -> Vec<Message> {
vec![
Message::user().with_text("What does the main entrypoint do?"),
Message::assistant().with_text("Let me read it."),
Message::user()
.with_text(turn_context_block)
.with_text("Now add error handling to it."),
Message::assistant().with_tool_request(
"tool_1",
Ok(CallToolRequestParams::new("read_file")
.with_arguments(object!({"path": "src/main.rs"}))),
),
Message::user().with_tool_response(
"tool_1",
Ok(CallToolResult::success(vec![
rmcp::model::ContentBlock::text("fn main() { run(); }"),
])),
),
]
}
fn request_with(messages: &[Message]) -> Value {
create_request_with_default_options(
Message::user().with_text("Now add error handling to it."),
];
let req = create_request_with_default_options(
&cfg("claude-sonnet-4-5"),
"You are a careful coding assistant.",
messages,
&messages,
&sample_tools(),
)
.unwrap()
}
.unwrap();
fn request(turn_context_block: &str) -> Value {
request_with(&conversation(turn_context_block))
}
#[test]
fn cached_prefix_is_invariant_to_turn_context_changes() {
let req_a = request(&turn_context("2026-06-25 12:00:00", "14/40 used"));
let req_b = request(&turn_context("2026-06-25 13:47:00", "31/40 used"));
assert_ne!(
req_a.to_string(),
req_b.to_string(),
"test setup is vacuous: the two requests are byte-identical, so the \
turn-context never reached the request body"
);
let tools = req["tools"].as_array().unwrap();
assert!(tools[0].get(CACHE_CONTROL_FIELD).is_none());
assert!(tools[1].get(CACHE_CONTROL_FIELD).is_some());
assert!(req["system"][0].get(CACHE_CONTROL_FIELD).is_some());
let request_messages = req["messages"].as_array().unwrap();
let marked = breakpoints(request_messages);
assert_eq!(
cached_prefix(&req_a),
cached_prefix(&req_b),
"the cached prefix changed when only the volatile turn-context changed; \
prefix caching will collapse into a per-turn cache write"
);
assert!(
!cached_prefix(&req_a).contains("12:00:00"),
"the volatile turn-context timestamp leaked into the cached prefix"
marked,
vec![(2, 0), (4, 0)],
"message breakpoints should sit on the last block of the last two user messages"
);
}
#[test]
fn turn_context_sits_after_every_cache_breakpoint() {
let req = request(&turn_context("2026-06-25 12:00:00", "14/40 used"));
let messages = req["messages"].as_array().unwrap();
fn breakpoints_land_on_the_last_content_block() {
let messages = vec![Message::user()
.with_text("Here is the context.")
.with_text("And the question.")];
let req = create_request_with_default_options(
&cfg("claude-sonnet-4-5"),
"You are a careful coding assistant.",
&messages,
&sample_tools(),
)
.unwrap();
for message in messages {
for block in message["content"].as_array().unwrap() {
if block.get(CACHE_CONTROL_FIELD).is_some() {
assert!(
!is_turn_context_block(block),
"a cache_control breakpoint landed on the volatile turn-context block"
);
}
}
}
let breakpoint = last_breakpoint(messages).expect("a breakpoint should exist");
let turn_context = find_turn_context(messages)
.expect("the turn-context block should survive into the formatted request");
assert!(
turn_context > breakpoint,
"turn-context at {turn_context:?} is not after the last cache breakpoint at \
{breakpoint:?}, so it sits inside a cached prefix"
);
}
/// Guards the tool-loop path: turn-context is injected onto an earlier
/// genuine user message while the request ends with a `tool_result`, so
/// keeping it out of the cached prefix requires relocating it across
/// messages. A regression that only reorders within a message would
/// pass the tests above but fail here.
#[test]
fn cached_prefix_is_invariant_in_tool_loop() {
let req_a = request_with(&tool_loop_conversation(&turn_context(
"2026-06-25 12:00:00",
"14/40",
)));
let req_b = request_with(&tool_loop_conversation(&turn_context(
"2026-06-25 13:47:00",
"31/40",
)));
assert_ne!(
req_a.to_string(),
req_b.to_string(),
"test setup is vacuous: turn-context never reached the request body"
);
assert_eq!(
cached_prefix(&req_a),
cached_prefix(&req_b),
"the cached prefix changed when only the volatile turn-context changed during a \
tool loop; the block was not relocated past the trailing tool_result breakpoint"
);
let messages = req_a["messages"].as_array().unwrap();
let breakpoint = last_breakpoint(messages).expect("a breakpoint should exist");
let turn_context = find_turn_context(messages)
.expect("the turn-context block should survive into the formatted request");
assert!(
turn_context > breakpoint,
"turn-context at {turn_context:?} was not relocated across messages to after the \
last breakpoint at {breakpoint:?}"
);
let request_messages = req["messages"].as_array().unwrap();
assert_eq!(breakpoints(request_messages), vec![(0, 1)]);
}
}
}

View file

@ -1,3 +1,4 @@
use crate::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics};
use crate::conversation::message::{Message, MessageContentBlock};
use crate::formats::anthropic::{
adaptive_output_effort, model_supports_temperature, thinking_budget_tokens,
@ -475,87 +476,6 @@ fn is_claude_model(model_name: &str) -> bool {
model_name.contains("claude")
}
/// Add Anthropic-style cache_control fields to the request payload for Claude models.
/// This enables prompt caching to reduce costs when using Claude via Databricks.
///
/// Cache control is added to:
/// - The system message
/// - The last two user messages (for incremental caching across turns)
/// - The last tool definition (so all tools are cached as a single prefix)
pub fn apply_cache_control_for_claude(payload: &mut Value) {
if let Some(messages_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("messages"))
.and_then(|messages| messages.as_array_mut())
{
// Add cache_control to the last two user messages for incremental caching.
// The last message gets cached so future turns can read from it.
// The second-to-last user message is also cached to read from the previous cache.
let mut user_count = 0;
for message in messages_spec.iter_mut().rev() {
if message.get("role") == Some(&json!("user")) {
if let Some(content) = message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*content = json!([{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]);
} else if let Some(content_array) = content.as_array_mut() {
// Content is already an array, add cache_control to the last element
if let Some(last_content) = content_array.last_mut() {
if let Some(obj) = last_content.as_object_mut() {
obj.insert(
"cache_control".to_string(),
json!({ "type": "ephemeral" }),
);
}
}
}
}
user_count += 1;
if user_count >= 2 {
break;
}
}
}
// Add cache_control to the system message
if let Some(system_message) = messages_spec
.iter_mut()
.find(|msg| msg.get("role") == Some(&json!("system")))
{
if let Some(content) = system_message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*system_message = json!({
"role": "system",
"content": [{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]
});
}
}
}
}
// Add cache_control to the last tool definition
if let Some(tools_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("tools"))
.and_then(|tools| tools.as_array_mut())
{
if let Some(last_tool) = tools_spec.last_mut() {
if let Some(function) = last_tool.get_mut("function") {
if let Some(obj) = function.as_object_mut() {
obj.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
}
}
}
}
}
#[allow(clippy::too_many_lines)]
pub fn create_request(
model_config: &ModelConfig,
@ -658,9 +578,9 @@ pub fn create_request_for_provider(
);
}
// Apply cache control for Claude models to enable prompt caching
if is_claude_model(&model_config.model_name) {
apply_cache_control_for_claude(&mut payload);
if CacheSemantics::for_model("databricks", &model_config.model_name).uses_explicit_breakpoints()
{
apply_chat_payload_breakpoints(&mut payload);
}
// Add request_params to the payload (e.g., anthropic_beta for extended context)
@ -1534,138 +1454,6 @@ mod tests {
assert!(!is_claude_model("databricks-meta-llama-3-3-70b"));
}
#[test]
fn test_apply_cache_control_for_claude_system_message() -> anyhow::Result<()> {
let mut payload = json!({
"model": "databricks-claude-sonnet-4",
"messages": [
{
"role": "system",
"content": "You are a helpful assistant."
},
{
"role": "user",
"content": "Hello"
}
]
});
apply_cache_control_for_claude(&mut payload);
let messages = payload["messages"].as_array().unwrap();
let system_msg = &messages[0];
// System message content should be converted to array with cache_control
assert!(system_msg["content"].is_array());
let content = system_msg["content"].as_array().unwrap();
assert_eq!(content.len(), 1);
assert_eq!(content[0]["type"], "text");
assert_eq!(content[0]["text"], "You are a helpful assistant.");
assert_eq!(content[0]["cache_control"]["type"], "ephemeral");
Ok(())
}
#[test]
fn test_apply_cache_control_for_claude_user_messages() -> anyhow::Result<()> {
let mut payload = json!({
"model": "databricks-claude-sonnet-4",
"messages": [
{
"role": "system",
"content": "You are helpful"
},
{
"role": "user",
"content": "First question"
},
{
"role": "assistant",
"content": "First answer"
},
{
"role": "user",
"content": "Second question"
},
{
"role": "assistant",
"content": "Second answer"
},
{
"role": "user",
"content": "Third question"
}
]
});
apply_cache_control_for_claude(&mut payload);
let messages = payload["messages"].as_array().unwrap();
// First user message should NOT have cache_control (only last 2)
let first_user = &messages[1];
assert_eq!(first_user["content"], "First question");
// Second-to-last user message should have cache_control
let second_user = &messages[3];
assert!(second_user["content"].is_array());
assert_eq!(
second_user["content"][0]["cache_control"]["type"],
"ephemeral"
);
// Last user message should have cache_control
let last_user = &messages[5];
assert!(last_user["content"].is_array());
assert_eq!(
last_user["content"][0]["cache_control"]["type"],
"ephemeral"
);
Ok(())
}
#[test]
fn test_apply_cache_control_for_claude_tools() -> anyhow::Result<()> {
let mut payload = json!({
"model": "databricks-claude-sonnet-4",
"messages": [
{
"role": "system",
"content": "You are helpful"
}
],
"tools": [
{
"type": "function",
"function": {
"name": "tool1",
"description": "First tool"
}
},
{
"type": "function",
"function": {
"name": "tool2",
"description": "Second tool"
}
}
]
});
apply_cache_control_for_claude(&mut payload);
let tools = payload["tools"].as_array().unwrap();
// First tool should NOT have cache_control
assert!(tools[0]["function"].get("cache_control").is_none());
// Last tool should have cache_control
assert_eq!(tools[1]["function"]["cache_control"]["type"], "ephemeral");
Ok(())
}
#[test]
fn test_format_messages_with_thought_signature_metadata() -> anyhow::Result<()> {
let mut metadata = serde_json::Map::new();

View file

@ -67,10 +67,6 @@ pub fn is_reserved_request_param_key(key: &str) -> bool {
pub struct OpenAiFormatOptions {
pub preserve_thinking_context: bool,
pub thinking_preservation_format: Option<ThinkingPreservationFormat>,
/// Keep the turn-context block on its source user message. The implicit
/// cache behind OpenAI's Responses-stack models only extends append-only
/// prompts, so the relocated tail would cap hits at the static prefix.
pub turn_context_in_place: bool,
}
fn merge_reasoning_text(prefix: &str, suffix: &str) -> String {
@ -213,15 +209,6 @@ pub fn format_messages_with_options(
image_format: &ImageFormat,
options: OpenAiFormatOptions,
) -> Vec<Value> {
let extracted = if options.turn_context_in_place {
None
} else {
extract_turn_context(messages)
};
let (messages, turn_context) = match &extracted {
Some((stripped, text)) => (stripped.as_slice(), Some(text.as_str())),
None => (messages, None),
};
let mut messages_spec = Vec::new();
let mut pending_assistant_reasoning = String::new();
// Reasoning to propagate across consecutive tool-call messages in the same turn.
@ -531,60 +518,9 @@ pub fn format_messages_with_options(
inline_reasoning_content(&mut messages_spec, format);
}
if let Some(text) = turn_context {
append_turn_context_tail(&mut messages_spec, text);
}
messages_spec
}
/// The volatile turn-context block busts implicit prefix caching from
/// mid-history; re-emitted at the request tail instead. Mirrors formats/anthropic.rs.
fn extract_turn_context(messages: &[Message]) -> Option<(Vec<Message>, String)> {
let (mi, bi) = messages.iter().enumerate().rev().find_map(|(mi, m)| {
if m.role != Role::User {
return None;
}
m.content
.iter()
.position(|block| {
matches!(block, MessageContentBlock::Text(t)
if crate::conversation::is_turn_context_text(&t.text))
})
.map(|bi| (mi, bi))
})?;
if mi + 1 != messages.len() && messages[mi].content.len() <= 1 {
return None;
}
let mut messages = messages.to_vec();
let MessageContentBlock::Text(text) = messages[mi].content.remove(bi) else {
return None;
};
Some((messages, text.text))
}
/// Merges into a trailing user message when one exists; strict chat templates
/// reject consecutive user messages.
fn append_turn_context_tail(messages_spec: &mut Vec<Value>, text: &str) {
if let Some(last) = messages_spec.last_mut() {
if last["role"] == json!("user") {
match last.get_mut("content") {
Some(Value::String(existing)) => {
existing.push('\n');
existing.push_str(text);
return;
}
Some(Value::Array(blocks)) => {
blocks.push(json!({"type": "text", "text": text}));
return;
}
_ => {}
}
}
}
messages_spec.push(json!({"role": "user", "content": text}));
}
/// Rewrites `reasoning_content` into the message `content` for models that reject a
/// separate reasoning field on replay.
///
@ -1628,8 +1564,6 @@ pub fn create_request_for_model_with_options(
"content": system
});
let mut format_options = format_options;
format_options.turn_context_in_place |= is_reasoning_model;
let messages_spec = format_messages_with_options(messages, image_format, format_options);
let mut tools_spec = format_tools(tools)?;
@ -4849,7 +4783,6 @@ data: [DONE]"#;
OpenAiFormatOptions {
preserve_thinking_context: true,
thinking_preservation_format: Some(format),
..Default::default()
},
)
}
@ -4965,8 +4898,8 @@ data: [DONE]"#;
Message::user().with_text("What does the main entrypoint do?"),
Message::assistant().with_text("Let me read it."),
Message::user()
.with_text(turn_context_block)
.with_text("Now add error handling to it."),
.with_text("Now add error handling to it.")
.with_text(turn_context_block),
Message::assistant().with_tool_request(
"tool_1",
Ok(CallToolRequestParams::new("read_file")
@ -4981,182 +4914,26 @@ data: [DONE]"#;
]
}
fn prefix(spec: &[Value]) -> String {
json!(spec[..spec.len() - 1]).to_string()
}
#[test]
fn formatted_prefix_is_invariant_to_turn_context_changes() {
let spec_a = format_messages(
&tool_loop_conversation(&turn_context("2026-08-03 12:00:00", "14/40 used")),
&ImageFormat::OpenAi,
);
let spec_b = format_messages(
&tool_loop_conversation(&turn_context("2026-08-03 13:47:00", "31/40 used")),
&ImageFormat::OpenAi,
);
assert_ne!(
json!(spec_a).to_string(),
json!(spec_b).to_string(),
"test setup is vacuous: the turn-context never reached the request body"
);
assert_eq!(
prefix(&spec_a),
prefix(&spec_b),
"the formatted prefix changed when only the volatile turn-context changed; \
implicit prefix caching will re-prefill the conversation tail every request"
);
}
#[test]
fn turn_context_is_relocated_to_a_trailing_user_message() {
fn turn_context_stays_on_its_source_message() {
let block = turn_context("2026-08-03 12:00:00", "14/40 used");
let spec = format_messages(&tool_loop_conversation(&block), &ImageFormat::OpenAi);
let last = spec.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"], json!(block));
let occurrences = spec
let occurrences: Vec<usize> = spec
.iter()
.filter(|m| {
.enumerate()
.filter_map(|(i, m)| {
m["content"]
.as_str()
.is_some_and(|c| c.contains("<turn-context>"))
.then_some(i)
})
.count();
assert_eq!(occurrences, 1, "turn-context must appear exactly once");
.collect();
assert_eq!(
spec[2]["content"],
json!("Now add error handling to it."),
"the source user message must keep its genuine text untouched"
occurrences,
vec![2],
"turn-context must appear exactly once, on its source message"
);
}
#[test]
fn first_request_of_a_turn_keeps_turn_context_inside_the_user_message() {
let block = turn_context("2026-08-03 12:00:00", "1/40 used");
let messages = vec![Message::user()
.with_text(&block)
.with_text("What does the main entrypoint do?")];
let spec = format_messages(&messages, &ImageFormat::OpenAi);
assert_eq!(spec.len(), 1);
assert_eq!(
spec[0]["content"],
json!(format!("What does the main entrypoint do?\n{block}"))
);
}
#[test]
fn mid_history_sole_turn_context_is_left_in_place() {
let block = turn_context("2026-08-03 12:00:00", "14/40 used");
let messages = vec![
Message::user().with_text(&block),
Message::assistant().with_text("Understood."),
Message::user().with_text("Please refactor the argument parser."),
];
let spec = format_messages(&messages, &ImageFormat::OpenAi);
assert_eq!(spec.len(), 3);
assert_eq!(spec[0]["content"], json!(block));
assert_eq!(
spec[2]["content"],
json!("Please refactor the argument parser.")
);
}
#[test]
fn turn_context_merges_into_the_synthetic_image_message() {
let block = turn_context("2026-08-03 12:00:00", "14/40 used");
let mut messages = tool_loop_conversation(&block);
messages.pop();
messages.push(Message::user().with_tool_response(
"tool_1",
Ok(CallToolResult::success(vec![ContentBlock::image(
"aGVsbG8=",
"image/png",
)])),
));
let spec = format_messages(&messages, &ImageFormat::OpenAi);
let last = spec.last().unwrap();
assert_eq!(last["role"], "user");
let blocks = last["content"].as_array().unwrap();
assert!(blocks.iter().any(|b| b["type"] == json!("image_url")));
assert_eq!(blocks.last().unwrap()["text"], json!(block));
for pair in spec.windows(2) {
assert!(
!(pair[0]["role"] == json!("user") && pair[1]["role"] == json!("user")),
"consecutive user messages reached the wire: {pair:?}"
);
}
}
fn responses_stack_request(messages: &[Message]) -> Vec<Value> {
let request = create_request(
&test_model_config("openai/gpt-5.6-terra"),
"system",
messages,
&[],
&ImageFormat::OpenAi,
false,
)
.unwrap();
request["messages"].as_array().unwrap().clone()
}
#[test]
fn responses_stack_tool_loop_requests_are_append_only() {
let block = turn_context("2026-08-03 12:00:00", "14/40 used");
let earlier = tool_loop_conversation(&block);
let mut later = earlier.clone();
later.push(
Message::assistant().with_tool_request(
"tool_2",
Ok(CallToolRequestParams::new("read_file")
.with_arguments(object!({"path": "src/lib.rs"}))),
),
);
later.push(Message::user().with_tool_response(
"tool_2",
Ok(CallToolResult::success(vec![ContentBlock::text(
"pub fn run() {}",
)])),
));
let spec_earlier = responses_stack_request(&earlier);
let spec_later = responses_stack_request(&later);
assert!(spec_later.len() > spec_earlier.len());
for (i, earlier_msg) in spec_earlier.iter().enumerate() {
assert_eq!(
json!(earlier_msg).to_string(),
json!(spec_later[i]).to_string(),
"request N must be a byte prefix of request N+1 at index {i}"
);
}
}
#[test]
fn chat_stack_models_still_relocate_turn_context_to_the_tail() {
let block = turn_context("2026-08-03 12:00:00", "14/40 used");
let request = create_request(
&test_model_config("openai/gpt-4.1-mini"),
"system",
&tool_loop_conversation(&block),
&[],
&ImageFormat::OpenAi,
false,
)
.unwrap();
let spec = request["messages"].as_array().unwrap();
let last = spec.last().unwrap();
assert_eq!(last["role"], "user");
assert_eq!(last["content"], json!(block));
}
}
}

View file

@ -1,4 +1,5 @@
pub mod base;
pub mod cache_semantics;
pub mod canonical;
pub mod conversation;
pub mod errors;

View file

@ -0,0 +1,428 @@
//! Across the consecutive requests of a session, the cache-relevant bytes a
//! provider has already seen must never change.
//!
//! Implicit caches require request N to be a verbatim item-prefix of request
//! N+1; explicit-breakpoint caches require the bytes up to the last
//! breakpoint to recur at the same positions, modulo the markers themselves.
//! Session states are shaped as the agent persists them and run through the
//! same projection and `fix_conversation` pass as the real request path.
use goose_provider_types::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics};
use goose_provider_types::conversation::message::{Message, MessageMetadata};
use goose_provider_types::conversation::{
fix_conversation, merge_consecutive_messages_for_request, Conversation,
};
use goose_provider_types::formats::anthropic::{self, AnthropicFormatOptions};
use goose_provider_types::formats::{databricks, openai, openai_responses};
use goose_provider_types::images::ImageFormat;
use goose_provider_types::model::ModelConfig;
use rmcp::model::{CallToolRequestParams, CallToolResult, ContentBlock, Tool};
use rmcp::object;
use serde_json::{json, Value};
const SYSTEM: &str = "You are a careful coding assistant.";
fn tools() -> Vec<Tool> {
vec![Tool::new(
"read_file",
"Read a file from disk",
object!({
"type": "object",
"properties": { "path": { "type": "string" } }
}),
)]
}
fn turn_block(turn: usize) -> String {
format!(
"<turn-context>\n\
<current-time>2026-08-06 12:0{turn}:00 +02:00</current-time>\n\
<working-directory>/Users/me/code/goose</working-directory>\n\
<todo>step {turn} of the plan</todo>\n\
</turn-context>"
)
}
fn turn_context_message(turn: usize) -> Message {
Message::user()
.with_text(turn_block(turn))
.with_metadata(MessageMetadata::agent_only().with_turn_context())
}
fn tool_exchange(id: &str, path: &str) -> [Message; 2] {
[
Message::assistant().with_tool_request(
id,
Ok(CallToolRequestParams::new("read_file").with_arguments(object!({ "path": path }))),
),
Message::user().with_tool_response(
id,
Ok(CallToolResult::success(vec![ContentBlock::text(
"fn main() { run(); }",
)])),
),
]
}
/// Two turns with one tool-loop iteration each; every state appends to the
/// previous one.
fn session_states() -> Vec<Vec<Message>> {
let u1 = Message::user().with_text("What does the main entrypoint do?");
let u2 = Message::user().with_text("Now add error handling to it.");
let (tc1, tc2) = (turn_context_message(1), turn_context_message(2));
let [a1, t1] = tool_exchange("call_1", "src/main.rs");
let [a2, t2] = tool_exchange("call_2", "src/lib.rs");
let a_text = Message::assistant().with_text("It calls `run()`.");
vec![
vec![u1.clone(), tc1.clone()],
vec![u1.clone(), tc1.clone(), a1.clone(), t1.clone()],
vec![
u1.clone(),
tc1.clone(),
a1.clone(),
t1.clone(),
a_text.clone(),
u2.clone(),
tc2.clone(),
],
vec![u1, tc1, a1, t1, a_text, u2, tc2, a2, t2],
]
}
/// The projection every request goes through before any formatter
/// (`stream_response_from_provider`).
fn provider_view(messages: &[Message]) -> Vec<Message> {
let projected =
Conversation::new_unvalidated(messages.iter().cloned()).agent_visible_messages();
let (fixed, _) = fix_conversation(Conversation::new_unvalidated(projected));
merge_consecutive_messages_for_request(fixed.messages().clone())
}
fn anthropic_request(messages: &[Message]) -> Value {
anthropic::create_request(
"anthropic",
&ModelConfig::new("claude-sonnet-4-5"),
SYSTEM,
&provider_view(messages),
&tools(),
AnthropicFormatOptions::default(),
)
.unwrap()
}
fn openai_chat_messages(model: &str, messages: &[Message]) -> Vec<Value> {
openai::create_request(
&ModelConfig::new(model),
SYSTEM,
&provider_view(messages),
&tools(),
&ImageFormat::OpenAi,
false,
)
.unwrap()["messages"]
.as_array()
.unwrap()
.clone()
}
fn responses_input(messages: &[Message]) -> Vec<Value> {
openai_responses::create_responses_request(
&ModelConfig::new("gpt-5.2"),
SYSTEM,
&provider_view(messages),
&tools(),
)
.unwrap()["input"]
.as_array()
.unwrap()
.clone()
}
fn openrouter_style_request(messages: &[Message]) -> Value {
let mut payload = openai::create_request(
&ModelConfig::new("anthropic/claude-sonnet-4-5"),
SYSTEM,
&provider_view(messages),
&tools(),
&ImageFormat::OpenAi,
false,
)
.unwrap();
assert!(
CacheSemantics::for_model("openrouter", "anthropic/claude-sonnet-4-5")
.uses_explicit_breakpoints()
);
apply_chat_payload_breakpoints(&mut payload);
payload
}
fn databricks_request(messages: &[Message]) -> Value {
databricks::create_request(
&ModelConfig::new("databricks-claude-sonnet-4"),
SYSTEM,
&provider_view(messages),
&tools(),
&ImageFormat::OpenAi,
)
.unwrap()
}
/// Strip breakpoint markers and normalize string content to block form:
/// upstream caches hash the normalized bytes, not the marker encoding.
fn canonical(value: &Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(k, _)| k.as_str() != "cache_control")
.map(|(k, v)| {
if k == "content" && v.is_string() {
(
k.clone(),
json!([{ "type": "text", "text": v.as_str().unwrap() }]),
)
} else {
(k.clone(), canonical(v))
}
})
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(canonical).collect()),
other => other.clone(),
}
}
fn strict_violations(prev: &[Value], next: &[Value]) -> Vec<String> {
if next.len() < prev.len() {
return vec![format!(
"request shrank from {} to {} items",
prev.len(),
next.len()
)];
}
prev.iter()
.zip(next)
.enumerate()
.filter(|(_, (p, n))| p != n)
.map(|(i, (p, n))| format!("items diverge at index {i}:\n sent: {p}\n next: {n}"))
.collect()
}
fn last_breakpoint(messages: &[Value]) -> Option<(usize, usize)> {
let mut found = None;
for (mi, message) in messages.iter().enumerate() {
let blocks = match message["content"].as_array() {
Some(blocks) => blocks.len(),
None => 1,
};
for bi in 0..blocks {
let block = match message["content"].as_array() {
Some(array) => &array[bi],
None => message,
};
if block.get("cache_control").is_some() {
found = Some((mi, bi));
}
}
}
found
}
/// Everything the previous request cached (tools, system, messages up to its
/// last breakpoint) must recur verbatim in the next request.
fn explicit_violations(prev_request: &Value, next_request: &Value) -> Vec<String> {
let mut violations = Vec::new();
let prev_messages = prev_request["messages"].as_array().unwrap();
let Some((last_mi, last_bi)) = last_breakpoint(prev_messages) else {
violations.push("previous request carries no message breakpoint".into());
return violations;
};
let prev = canonical(prev_request);
let next = canonical(next_request);
for section in ["tools", "system"] {
if prev[section] != next[section] {
violations.push(format!(
"cached section `{section}` changed between requests"
));
}
}
let prev_messages = prev["messages"].as_array().unwrap();
let next_messages = next["messages"].as_array().unwrap();
if next_messages.len() <= last_mi {
violations.push("messages shrank below the previous breakpoint".into());
return violations;
}
for mi in 0..last_mi {
if prev_messages[mi] != next_messages[mi] {
violations.push(format!(
"cached bytes changed at message {mi}:\n sent: {}\n next: {}",
prev_messages[mi], next_messages[mi]
));
}
}
let prev_blocks = prev_messages[last_mi]["content"].as_array().unwrap();
match next_messages[last_mi]["content"].as_array() {
Some(next_blocks)
if next_blocks.len() > last_bi
&& prev_messages[last_mi]["role"] == next_messages[last_mi]["role"] =>
{
for bi in 0..=last_bi {
if prev_blocks[bi] != next_blocks[bi] {
violations.push(format!(
"cached bytes changed at message {last_mi} block {bi}:\n sent: {}\n next: {}",
prev_blocks[bi], next_blocks[bi]
));
}
}
}
_ => violations.push(format!("cached message {last_mi} changed shape")),
}
violations
}
fn consecutive<T>(items: &[T]) -> impl Iterator<Item = (usize, &T, &T)> {
items.windows(2).enumerate().map(|(i, w)| (i, &w[0], &w[1]))
}
fn assert_clean(label: &str, violations: Vec<String>) {
assert!(
violations.is_empty(),
"{label}: cache-relevant bytes changed between consecutive requests:\n{}",
violations.join("\n")
);
}
#[test]
fn anthropic_requests_stay_breakpoint_compatible() {
let requests: Vec<Value> = session_states()
.iter()
.map(|m| anthropic_request(m))
.collect();
assert_ne!(requests[0], requests[1], "vacuous: requests are identical");
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("anthropic pair {i}"),
explicit_violations(prev, next),
);
}
}
/// The chat fleet's caches are merely tolerant, but with no relocation left
/// the strict relation holds anyway.
#[test]
fn openai_chat_requests_are_append_only() {
let requests: Vec<Vec<Value>> = session_states()
.iter()
.map(|m| openai_chat_messages("gpt-4.1-mini", m))
.collect();
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("openai chat pair {i}"),
strict_violations(prev, next),
);
}
}
#[test]
fn responses_requests_are_append_only() {
let requests: Vec<Vec<Value>> = session_states()
.iter()
.map(|m| responses_input(m))
.collect();
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("responses pair {i}"),
strict_violations(prev, next),
);
}
}
#[test]
fn responses_stack_chat_requests_are_append_only() {
let requests: Vec<Vec<Value>> = session_states()
.iter()
.map(|m| openai_chat_messages("gpt-5.6-terra", m))
.collect();
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("responses-stack chat pair {i}"),
strict_violations(prev, next),
);
}
}
/// The OpenRouter/LiteLLM path: an OpenAI chat payload post-processed with
/// Anthropic-dialect breakpoints by the shared writer.
#[test]
fn openrouter_style_requests_stay_breakpoint_compatible() {
let requests: Vec<Value> = session_states()
.iter()
.map(|m| openrouter_style_request(m))
.collect();
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("openrouter pair {i}"),
explicit_violations(prev, next),
);
}
}
#[test]
fn databricks_requests_stay_breakpoint_compatible() {
let requests: Vec<Value> = session_states()
.iter()
.map(|m| databricks_request(m))
.collect();
for (i, prev, next) in consecutive(&requests) {
assert_clean(
&format!("databricks pair {i}"),
explicit_violations(prev, next),
);
}
}
#[test]
fn seeded_regression_volatile_bytes_in_cached_prefix_are_caught() {
let request_for = |stamp: &str| {
let block =
format!("<turn-context>\n<current-time>{stamp}</current-time>\n</turn-context>");
let [a1, t1] = tool_exchange("call_1", "src/main.rs");
anthropic_request(&[
Message::user()
.with_text(block)
.with_visibility(false, true),
Message::user().with_text("Now add error handling to it."),
a1,
t1,
])
};
let violations = explicit_violations(
&request_for("2026-08-06 12:00:00"),
&request_for("2026-08-06 12:01:00"),
);
assert!(
!violations.is_empty(),
"expected per-request volatile bytes inside the cached prefix to be caught"
);
}
#[test]
fn seeded_regression_relocated_tail_is_caught() {
let states = session_states();
let prev = responses_input(&states[0]);
let mut relocated_state = states[1].clone();
let block = relocated_state.remove(1);
relocated_state.push(block);
let next = responses_input(&relocated_state);
let violations = strict_violations(&prev, &next);
assert!(
!violations.is_empty(),
"expected a relocated turn-context block to violate the append-only relation"
);
}

View file

@ -6,8 +6,8 @@ pub mod databricks_auth;
pub mod databricks_v2;
pub mod google;
pub use goose_provider_types::{
base, canonical, conversation, errors, formats, goose_mode, images, json, model, permission,
request_log, retry, thinking, utils,
base, cache_semantics, canonical, conversation, errors, formats, goose_mode, images, json,
model, permission, request_log, retry, thinking, utils,
};
pub mod declarative;
pub mod http_status;

View file

@ -782,7 +782,6 @@ impl Provider for OpenAiProvider {
preserve_thinking_context: self.preserve_thinking_context
|| thinking_preservation_format.is_some(),
thinking_preservation_format,
..Default::default()
},
)?;

View file

@ -1442,14 +1442,14 @@ fn messages_to_prompt(messages: &[Message], include_handoff_context: bool) -> Ve
fn last_user_message_index(messages: &[Message]) -> Option<usize> {
messages
.iter()
.rposition(|m| m.role == Role::User && m.is_agent_visible())
.rposition(|m| m.role == Role::User && m.is_agent_visible() && !m.is_turn_context())
}
fn has_handoff_context(messages: &[Message]) -> bool {
last_user_message_index(messages).is_some_and(|last_user_index| {
messages[..last_user_index]
.iter()
.any(Message::is_agent_visible)
.any(|m| m.is_agent_visible() && !m.is_turn_context())
})
}
@ -1458,6 +1458,7 @@ fn build_handoff_context_memo(prior_messages: &[Message]) -> Option<String> {
Conversation::new_unvalidated(prior_messages.iter().cloned())
.agent_visible_messages()
.iter()
.filter(|message| !message.is_turn_context())
.map(|message| format_message_for_compacting(&message.agent_visible_content()))
.collect();
@ -1810,6 +1811,37 @@ mod tests {
assert_eq!(prompt_text(&blocks[1]), "continue from there");
}
#[test]
fn messages_to_prompt_skips_turn_context_events() {
use crate::conversation::message::MessageMetadata;
let turn_context = |text: &str| {
Message::user()
.with_text(text)
.with_metadata(MessageMetadata::agent_only().with_turn_context())
};
let messages = vec![
Message::user().with_text("inspect src/lib.rs"),
turn_context("<turn-context>old cwd /repo</turn-context>"),
Message::assistant().with_text("I found the file"),
Message::user().with_text("continue from there"),
turn_context("<turn-context>new cwd /repo</turn-context>"),
];
let blocks = messages_to_prompt(&messages, true);
assert_eq!(blocks.len(), 2);
assert!(
!prompt_text(&blocks[0]).contains("turn-context"),
"handoff memo must not include turn-context events"
);
assert_eq!(
prompt_text(&blocks[1]),
"continue from there",
"the current prompt must be the user's request, not a trailing turn-context event"
);
}
#[test]
fn messages_to_prompt_drops_user_only_acp_rows_from_handoff() {
let user_only = TextContent::new("SECRET_USER_ONLY")

View file

@ -46,7 +46,9 @@ use crate::conversation::message::{
ActionRequiredData, InferenceMetadata, Message, MessageContent, MessageUsage, ProviderMetadata,
SystemNotificationType, ToolRequest,
};
use crate::conversation::{debug_conversation_fix, fix_conversation, Conversation};
use crate::conversation::{
debug_conversation_fix, fix_conversation, merge_consecutive_messages_for_request, Conversation,
};
use crate::mcp_utils::ToolResult;
use crate::permission::permission_inspector::PermissionInspector;
use crate::permission::permission_judge::PermissionCheckResult;
@ -150,7 +152,6 @@ pub struct ReplyContext {
pub system_prompt: String,
pub goose_mode: GooseMode,
pub tool_call_cut_off: usize,
pub initial_messages: Vec<Message>,
pub model_config: goose_providers::model::ModelConfig,
}
@ -787,8 +788,6 @@ impl Agent {
)
);
}
let initial_messages = conversation.messages().clone();
let (tools, toolshim_tools, system_prompt, model_config) = self
.prepare_tools_and_prompt(session_id, working_dir)
.await?;
@ -820,7 +819,6 @@ impl Agent {
system_prompt,
goose_mode,
tool_call_cut_off,
initial_messages,
model_config,
})
}
@ -2154,7 +2152,6 @@ impl Agent {
mut system_prompt,
tool_call_cut_off,
goose_mode,
initial_messages,
model_config,
} = context;
@ -2264,7 +2261,27 @@ impl Agent {
let turn_start_compaction_info =
super::moim::compute_compaction_info(&session_config.id, &self.extension_manager)
.await;
let turn_start_turns_taken = turns_taken;
if let Some(turn_context) = super::moim::turn_context_message(
&session_config.id,
&self.extension_manager,
turns_taken,
max_turns,
turn_start,
turn_start_compaction_info,
)
.await
{
persist_and_push_message_with_id(
&session_manager,
&session_config.id,
&mut conversation,
turn_context,
)
.await?;
}
// Snapshot after the turn-context append so a retry keeps the sent prefix.
let initial_messages = conversation.messages().clone();
loop {
if is_token_cancelled(&cancel_token) {
@ -2359,23 +2376,12 @@ impl Agent {
break;
}
let conversation_with_moim = super::moim::inject_moim(
&session_config.id,
conversation.clone(),
&self.extension_manager,
turn_start_turns_taken,
max_turns,
turn_start,
turn_start_compaction_info.clone(),
)
.await;
let mut stream = crate::agents::reply_parts::stream_response_from_provider(
self.provider().await?,
model_config.clone(),
&session_config.id,
&system_prompt,
conversation_with_moim.messages(),
conversation.messages(),
&tools,
&toolshim_tools,
).await?;
@ -3734,7 +3740,7 @@ impl Agent {
.filter(super::reply_parts::is_tool_visible_to_model)
.collect();
messages = Conversation::new_unvalidated(messages.agent_visible_messages());
messages = Conversation::new_unvalidated(recipe_conversation_history(&messages));
messages.push(Message::user().with_text(recipe_prompt));
let (messages, issues) = fix_conversation(messages);
@ -3743,6 +3749,9 @@ impl Agent {
.iter()
.for_each(|issue| tracing::warn!(recipe.conversation.issue = issue));
}
let messages = Conversation::new_unvalidated(merge_consecutive_messages_for_request(
messages.messages().clone(),
));
tracing::debug!(
"Added recipe prompt to messages, total messages: {}",
@ -3908,6 +3917,15 @@ impl Agent {
}
}
fn recipe_conversation_history(messages: &Conversation) -> Vec<Message> {
// The recipe prompt has no turn-context instructions; drop the blocks.
messages
.agent_visible_messages()
.into_iter()
.filter(|message| !message.is_turn_context())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@ -3923,6 +3941,25 @@ mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use tempfile::TempDir;
#[test]
fn recipe_history_excludes_turn_context_events() {
use crate::conversation::message::MessageMetadata;
let history = Conversation::new_unvalidated([
Message::user().with_text("build me a recipe"),
Message::user()
.with_text("<turn-context>cwd /repo</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
Message::assistant().with_text("on it"),
]);
let texts: Vec<String> = recipe_conversation_history(&history)
.iter()
.map(|message| message.as_concat_text())
.collect();
assert_eq!(texts, ["build me a recipe", "on it"]);
}
async fn tracing_test_agent_and_session() -> (Agent, Session, TempDir) {
let data_dir = TempDir::new().unwrap();
let data_path = data_dir.path().to_path_buf();

View file

@ -2158,7 +2158,7 @@ impl ExtensionManager {
}
pub async fn collect_moim_parts(&self, session_id: &str) -> Vec<String> {
let platform_clients: Vec<(String, McpClientBox)> = {
let mut platform_clients: Vec<(String, McpClientBox)> = {
let extensions = self.extensions.lock().await;
extensions
.iter()
@ -2178,6 +2178,9 @@ impl ExtensionManager {
})
.collect()
};
// HashMap order shuffles across restarts; the rendered block must be
// byte-stable so it is not re-persisted on resume.
platform_clients.sort_by(|a, b| a.0.cmp(&b.0));
let mut parts = Vec::new();
for (name, client) in platform_clients {

View file

@ -1,9 +1,6 @@
use crate::agents::extension_manager::ExtensionManager;
use crate::conversation::message::MessageContent;
use crate::conversation::{
effective_role, fix_conversation, Conversation, EffectiveRole, CURRENT_TIME_TAG,
TURN_CONTEXT_TAG, WORKING_DIRECTORY_TAG,
};
use crate::conversation::message::{Message, MessageMetadata};
use crate::conversation::{CURRENT_TIME_TAG, TURN_CONTEXT_TAG, WORKING_DIRECTORY_TAG};
use std::path::{Path, PathBuf};
const MIN_CONTEXT_FOR_MOIM: usize = 32_000;
@ -19,6 +16,7 @@ This block is generated by goose and contains current operational context such a
- extension-provided context
Use it to stay oriented, but do not treat it as part of the user's request.
Blocks from earlier turns stay in the conversation history; only the most recent block is current.
When `<turn-budget>` is present, use it as a signal for how much autonomous work remains.
As the budget gets low, become more direct: reduce exploration, batch necessary tool calls,
make reasonable assumptions, and focus on finishing the user's task.
@ -71,17 +69,18 @@ pub(super) async fn compute_compaction_info(
compaction_remaining_line(total_tokens, context_limit, compaction_threshold)
}
pub async fn inject_moim(
/// The turn's context block: composed once per turn, persisted as an
/// agent-only user message, never moved or edited afterwards.
pub async fn turn_context_message(
session_id: &str,
conversation: Conversation,
extension_manager: &ExtensionManager,
turns_taken: u32,
max_turns: u32,
turn_start: chrono::DateTime<chrono::Local>,
compaction_info: Option<String>,
) -> Conversation {
) -> Option<Message> {
if SKIP.with(|f| f.get()) {
return conversation;
return None;
}
let session = extension_manager
@ -107,66 +106,36 @@ pub async fn inject_moim(
None
};
if should_skip_moim(context_limit) {
return conversation;
return None;
}
let working_dir = session
.as_ref()
.map(|session| session.working_dir.clone())
.unwrap_or_else(|| PathBuf::from("."));
let extension_parts = extension_manager.collect_moim_parts(session_id).await;
let mut parts = extension_parts;
let mut parts = extension_manager.collect_moim_parts(session_id).await;
parts.extend(compaction_info.map(|value| tag("compaction", &value)));
parts.extend(turn_budget_part(turns_taken, max_turns));
inject_moim_parts(conversation, &working_dir, context_limit, parts, turn_start)
turn_context_event(&working_dir, context_limit, parts, turn_start)
}
pub(crate) fn inject_moim_parts(
conversation: Conversation,
/// The state-machine variant: operations contribute the parts and the caller
/// persists the event, so later requests in the turn reuse the same bytes.
pub(crate) fn turn_context_event(
working_dir: &Path,
context_limit: Option<usize>,
parts: Vec<String>,
turn_start: chrono::DateTime<chrono::Local>,
) -> Conversation {
) -> Option<Message> {
if SKIP.with(|f| f.get()) || should_skip_moim(context_limit) {
return conversation;
return None;
}
let moim = compose_moim(working_dir, parts, turn_start);
let mut messages = conversation.messages().clone();
let Some(idx) = messages
.iter()
.rposition(|m| m.is_agent_visible() && effective_role(m) == EffectiveRole::User)
else {
return conversation;
};
let insert_idx = messages[idx]
.content
.iter()
.take_while(|content| matches!(content, MessageContent::ToolResponse(_)))
.count();
messages[idx]
.content
.insert(insert_idx, MessageContent::text(moim));
let (fixed, issues) = fix_conversation(Conversation::new_unvalidated(messages));
let has_unexpected_issues = issues.iter().any(|issue| {
!issue.contains("Merged consecutive user messages")
&& !issue.contains("Merged consecutive assistant messages")
&& !issue.contains("Added placeholder to empty tool result")
&& !issue.contains("Trimmed trailing whitespace from assistant message")
&& !issue.contains("Removed trailing assistant message")
&& !issue.contains("Merged text content")
});
if has_unexpected_issues {
tracing::warn!("MOIM injection caused unexpected issues: {:?}", issues);
return conversation;
}
fixed
Some(
Message::user()
.with_text(compose_moim(working_dir, parts, turn_start))
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
)
}
fn should_skip_moim(context_limit: Option<usize>) -> bool {
@ -252,21 +221,8 @@ fn turn_budget_part(turns_taken: u32, max_turns: u32) -> Option<String> {
#[cfg(test)]
mod tests {
use super::*;
use crate::conversation::message::Message;
use rmcp::model::CallToolRequestParams;
fn text_at(message: &crate::conversation::message::Message, index: usize) -> &str {
message.content[index].as_text().unwrap()
}
fn is_moim(content: &MessageContent) -> bool {
content
.as_text()
.is_some_and(|text| text.starts_with(&format!("<{}>\n", TURN_CONTEXT_TAG)))
}
#[tokio::test]
async fn test_moim_prepended_to_latest_user_message() {
async fn session_and_manager() -> (String, ExtensionManager, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().unwrap();
let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf());
let session = em
@ -280,209 +236,83 @@ mod tests {
)
.await
.unwrap();
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Hello"),
Message::assistant().with_text("Hi"),
Message::user().with_text("Bye"),
]);
let result = inject_moim(&session.id, conv, &em, 0, 100, chrono::Local::now(), None).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 3);
assert_eq!(text_at(&msgs[0], 0), "Hello");
assert_eq!(text_at(&msgs[1], 0), "Hi");
assert!(is_moim(&msgs[2].content[0]));
assert_eq!(text_at(&msgs[2], 1), "Bye");
(session.id, em, temp_dir)
}
#[tokio::test]
async fn test_moim_injection_no_assistant() {
let temp_dir = tempfile::tempdir().unwrap();
let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf());
let session = em
.get_context()
.session_manager
.create_session(
PathBuf::from("/test/dir"),
"test".to_string(),
crate::session::SessionType::User,
crate::config::GooseMode::Auto,
)
async fn turn_context_message_is_an_agent_only_user_message() {
let (session_id, em, _tmp) = session_and_manager().await;
let message = turn_context_message(&session_id, &em, 0, 100, chrono::Local::now(), None)
.await
.unwrap();
.expect("turn context should be produced");
let conv = Conversation::new_unvalidated(vec![Message::user().with_text("Hello")]);
let result = inject_moim(&session.id, conv, &em, 0, 100, chrono::Local::now(), None).await;
assert_eq!(result.messages().len(), 1);
assert!(is_moim(&result.messages()[0].content[0]));
assert_eq!(text_at(&result.messages()[0], 1), "Hello");
assert_eq!(message.role, rmcp::model::Role::User);
assert!(message.is_agent_visible());
assert!(!message.is_user_visible());
assert!(message.is_turn_context());
let text = message.content[0].as_text().unwrap();
assert!(text.starts_with(&format!("<{TURN_CONTEXT_TAG}>\n")));
assert!(text.trim_end().ends_with(&format!("</{TURN_CONTEXT_TAG}>")));
}
#[tokio::test]
async fn test_moim_skips_user_messages_not_visible_to_agent() {
let temp_dir = tempfile::tempdir().unwrap();
let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf());
let session = em
.get_context()
.session_manager
.create_session(
PathBuf::from("/test/dir"),
"test".to_string(),
crate::session::SessionType::User,
crate::config::GooseMode::Auto,
)
async fn turn_context_bytes_are_stable_for_a_turn() {
let (session_id, em, _tmp) = session_and_manager().await;
let turn_start = chrono::Local::now();
let first = turn_context_message(&session_id, &em, 0, 100, turn_start, None)
.await
.unwrap();
let second = turn_context_message(&session_id, &em, 0, 100, turn_start, None)
.await
.unwrap();
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("agent visible"),
Message::assistant().with_text("reply"),
Message::user().with_text("user only").user_only(),
]);
let result = inject_moim(&session.id, conv, &em, 0, 100, chrono::Local::now(), None).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 2);
assert!(is_moim(&msgs[0].content[0]));
assert_eq!(text_at(&msgs[0], 1), "agent visible");
assert_eq!(text_at(&msgs[1], 0), "user only");
assert!(!msgs[1].is_agent_visible());
assert_eq!(
first.content[0].as_text(),
second.content[0].as_text(),
"the same turn inputs must render byte-identical blocks"
);
}
#[tokio::test]
async fn test_moim_with_tool_calls() {
let temp_dir = tempfile::tempdir().unwrap();
let em = ExtensionManager::new_without_provider(temp_dir.path().to_path_buf());
let session = em
.get_context()
.session_manager
.create_session(
PathBuf::from("/test/dir"),
"test".to_string(),
crate::session::SessionType::User,
crate::config::GooseMode::Auto,
)
.await
.unwrap();
#[test]
fn turn_context_event_is_marked_and_skips_small_contexts() {
let event = turn_context_event(
Path::new("/test/dir"),
Some(200_000),
vec![],
chrono::Local::now(),
)
.expect("large-context models should get a turn-context event");
assert!(event.is_turn_context());
assert!(!event.is_user_visible());
let conv = Conversation::new_unvalidated(vec![
Message::user().with_text("Search for something"),
Message::assistant()
.with_text("I'll search for you")
.with_tool_request("search_1", Ok(CallToolRequestParams::new("search"))),
Message::user()
.with_tool_response("search_1", Ok(rmcp::model::CallToolResult::success(vec![]))),
]);
let result = inject_moim(&session.id, conv, &em, 0, 100, chrono::Local::now(), None).await;
let msgs = result.messages();
assert_eq!(msgs.len(), 3);
assert!(is_moim(&msgs[0].content[0]));
assert_eq!(text_at(&msgs[0], 1), "Search for something");
assert!(matches!(
&msgs[2].content[0],
MessageContent::ToolResponse(_)
));
assert_eq!(msgs[2].content.len(), 1);
assert!(turn_context_event(
Path::new("/test/dir"),
Some(16_000),
vec![],
chrono::Local::now(),
)
.is_none());
}
/// The turn-context block is produced here (`compose_moim`, in goose) but
/// recognized in a different crate (`is_turn_context_text`, in
/// goose-providers) by matching its textual shape. That recognition is the
/// only thing that keeps the volatile block out of the Anthropic cache
/// prefix. The two are coupled by string layout alone, with no shared type
/// or compiler check, so a reorder or rename on either side would silently
/// collapse prompt caching. These tests fail loudly if they drift apart.
mod turn_context_detector_coupling {
use super::*;
use crate::conversation::is_turn_context_text;
use std::path::Path;
#[test]
fn current_time_is_timezone_aware() {
let block = compose_moim(
Path::new("/Users/me/code/goose"),
vec![],
chrono::Local::now(),
);
let time_line = block
.lines()
.find(|line| line.starts_with(&format!("<{CURRENT_TIME_TAG}>")))
.expect("turn-context block should contain a current-time line");
fn moim(
total_tokens: Option<i32>,
context_limit: Option<usize>,
turns_taken: u32,
max_turns: u32,
mut parts: Vec<String>,
) -> String {
parts.extend(
compaction_remaining_line(total_tokens, context_limit, 0.8)
.map(|value| tag("compaction", &value)),
);
parts.extend(turn_budget_part(turns_taken, max_turns));
compose_moim(
Path::new("/Users/me/code/goose"),
parts,
chrono::Local::now(),
)
}
let value = time_line
.trim_start_matches(&format!("<{CURRENT_TIME_TAG}>"))
.trim_end_matches(&format!("</{CURRENT_TIME_TAG}>"));
#[test]
fn compose_moim_output_is_recognized_by_the_anthropic_detector() {
let guardrail = || vec!["<guardrail>stay on task</guardrail>".to_string()];
let cases = [
("minimal", moim(None, None, 0, 0, vec![])),
(
"with compaction line",
moim(Some(100_000), Some(200_000), 0, 0, vec![]),
),
("with turn-budget line", moim(None, None, 20, 40, vec![])),
(
"with extension context",
moim(None, None, 0, 0, guardrail()),
),
(
"fully populated",
moim(Some(100_000), Some(200_000), 20, 40, guardrail()),
),
];
for (label, block) in &cases {
assert!(
is_turn_context_text(block),
"is_turn_context_text rejected real compose_moim output ({label}); the \
producer in goose and the detector in goose-providers have drifted, so the \
volatile block will no longer be kept out of the Anthropic cache prefix:\n\
{block}"
);
}
}
#[test]
fn detector_rejects_text_that_is_not_a_turn_context_block() {
assert!(
!is_turn_context_text("Please refactor the argument parser."),
"ordinary user text must not be treated as a turn-context block"
);
assert!(
!is_turn_context_text(
"<turn-context>\n\
<current-time>2026-06-25 12:00:00</current-time>\n\
<session-id>abc</session-id>\n\
</turn-context>"
),
"a block whose first field after current-time is not working-directory must not \
match: the detector's shape checks are what discriminate the injected block"
);
}
#[test]
fn current_time_is_timezone_aware() {
let block = moim(None, None, 0, 0, vec![]);
let time_line = block
.lines()
.find(|line| line.starts_with(&format!("<{CURRENT_TIME_TAG}>")))
.expect("turn-context block should contain a current-time line");
let value = time_line
.trim_start_matches(&format!("<{CURRENT_TIME_TAG}>"))
.trim_end_matches(&format!("</{CURRENT_TIME_TAG}>"));
chrono::DateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S %:z")
.expect("current-time should include a numeric UTC offset");
}
chrono::DateTime::parse_from_str(value, "%Y-%m-%d %H:%M:%S %:z")
.expect("current-time should include a numeric UTC offset");
}
}

View file

@ -41,7 +41,11 @@ pub struct ChatRecallClient {
}
fn format_agent_visible_excerpt(conversation: &Conversation) -> Option<(usize, String)> {
let messages = conversation.agent_visible_messages();
let messages: Vec<_> = conversation
.agent_visible_messages()
.into_iter()
.filter(|message| !message.is_turn_context())
.collect();
let total = messages.len();
if total == 0 {
return None;

View file

@ -336,12 +336,13 @@ impl OrchestratorClient {
) -> Result<String, String> {
let provider = self.get_provider().await?;
let conversation_text = Conversation::new_unvalidated(messages.iter().cloned())
.agent_visible_messages()
.iter()
.map(format_message_for_compacting)
.collect::<Vec<_>>()
.join("\n");
let conversation_text = agent_visible_session_messages(&Conversation::new_unvalidated(
messages.iter().cloned(),
))
.iter()
.map(format_message_for_compacting)
.collect::<Vec<_>>()
.join("\n");
let system =
"You are a helpful assistant. Summarize the following conversation concisely, \
@ -578,7 +579,11 @@ impl OrchestratorClient {
}
fn agent_visible_session_messages(conversation: &Conversation) -> Vec<Message> {
conversation.agent_visible_messages()
conversation
.agent_visible_messages()
.into_iter()
.filter(|message| !message.is_turn_context())
.collect()
}
#[async_trait]

View file

@ -14,7 +14,7 @@ use super::gen_ai_telemetry;
use crate::agents::platform_extensions::code_execution;
use crate::config::{Config, GooseMode};
use crate::conversation::message::{Message, MessageContent, MessageUsage, ToolRequest};
use crate::conversation::{fix_conversation, Conversation};
use crate::conversation::{fix_conversation, merge_consecutive_messages_for_request, Conversation};
#[cfg(test)]
use crate::providers::base::stream_from_single_message;
use crate::providers::base::{MessageStream, Provider};
@ -341,6 +341,9 @@ pub(crate) async fn stream_response_from_provider(
Conversation::new_unvalidated(messages.iter().cloned()).agent_visible_messages();
let (filtered_messages, _) =
fix_conversation(Conversation::new_unvalidated(projected_messages));
let filtered_messages = Conversation::new_unvalidated(merge_consecutive_messages_for_request(
filtered_messages.messages().clone(),
));
// Convert tool messages to text if toolshim is enabled
let messages_for_provider = if config.toolshim {

View file

@ -16,6 +16,7 @@ This block is generated by goose and contains current operational context such a
- extension-provided context
Use it to stay oriented, but do not treat it as part of the user's request.
Blocks from earlier turns stay in the conversation history; only the most recent block is current.
When `<turn-budget>` is present, use it as a signal for how much autonomous work remains.
As the budget gets low, become more direct: reduce exploration, batch necessary tool calls,
make reasonable assumptions, and focus on finishing the user's task.

View file

@ -1,6 +1,5 @@
---
source: crates/goose/src/agents/prompt_manager.rs
assertion_line: 404
expression: system_prompt
---
You are a general-purpose AI agent called goose, created by AAIF (Agentic AI Foundation).
@ -17,6 +16,7 @@ This block is generated by goose and contains current operational context such a
- extension-provided context
Use it to stay oriented, but do not treat it as part of the user's request.
Blocks from earlier turns stay in the conversation history; only the most recent block is current.
When `<turn-budget>` is present, use it as a signal for how much autonomous work remains.
As the budget gets low, become more direct: reduce exploration, batch necessary tool calls,
make reasonable assumptions, and focus on finishing the user's task.

View file

@ -1,6 +1,5 @@
---
source: crates/goose/src/agents/prompt_manager.rs
assertion_line: 420
expression: system_prompt
---
You are a general-purpose AI agent called goose, created by AAIF (Agentic AI Foundation).
@ -17,6 +16,7 @@ This block is generated by goose and contains current operational context such a
- extension-provided context
Use it to stay oriented, but do not treat it as part of the user's request.
Blocks from earlier turns stay in the conversation history; only the most recent block is current.
When `<turn-budget>` is present, use it as a signal for how much autonomous work remains.
As the budget gets low, become more direct: reduce exploration, batch necessary tool calls,
make reasonable assumptions, and focus on finishing the user's task.

View file

@ -1,6 +1,5 @@
---
source: crates/goose/src/agents/prompt_manager.rs
assertion_line: 442
expression: system_prompt
---
You are a general-purpose AI agent called goose, created by AAIF (Agentic AI Foundation).
@ -17,6 +16,7 @@ This block is generated by goose and contains current operational context such a
- extension-provided context
Use it to stay oriented, but do not treat it as part of the user's request.
Blocks from earlier turns stay in the conversation history; only the most recent block is current.
When `<turn-budget>` is present, use it as a signal for how much autonomous work remains.
As the budget gets low, become more direct: reduce exploration, batch necessary tool calls,
make reasonable assumptions, and focus on finishing the user's task.

View file

@ -434,18 +434,32 @@ impl Inference for InferenceRunner<'_> {
.get_context_limit(&self.model_config)
.await
.unwrap_or_else(|_| self.model_config.context_limit());
let turn_start = messages_since_kickoff(conversation)?
let turn = messages_since_kickoff(conversation)?;
let turn_start = turn
.first()
.and_then(|message| chrono::DateTime::from_timestamp(message.created, 0))
.map(|timestamp| timestamp.with_timezone(&chrono::Local))
.unwrap_or_else(chrono::Local::now);
let conversation_for_provider = crate::agents::moim::inject_moim_parts(
Conversation::new_unvalidated(messages_for_provider),
// Persist a fresh turn-context event only when its bytes changed
// (e.g. the turn budget ticked); earlier events stay in place.
let last_turn_context = turn
.iter()
.rev()
.find(|message| message.is_turn_context())
.map(Message::as_concat_text);
let turn_context = crate::agents::moim::turn_context_event(
&session.working_dir,
Some(context_limit),
input.moim_parts,
turn_start,
);
)
.filter(|event| Some(event.as_concat_text()) != last_turn_context);
if let Some(event) = &turn_context {
messages_for_provider.push(event.clone());
}
let conversation_for_provider = Conversation::new_unvalidated(messages_for_provider);
let mut usage_effects: Vec<StateEffect> =
turn_context.into_iter().map(StateEffect::from).collect();
let stream = crate::agents::reply_parts::stream_response_from_provider(
self.provider.clone(),
@ -460,7 +474,10 @@ impl Inference for InferenceRunner<'_> {
let mut stream = match stream {
Ok(stream) => stream,
Err(err) => return applied(self.error_outcome(&err, emit).await),
Err(err) => {
usage_effects.extend(self.error_outcome(&err, emit).await);
return applied(usage_effects);
}
};
let requested_model = self.model_config.model_name.clone();
@ -477,7 +494,6 @@ impl Inference for InferenceRunner<'_> {
});
let mut accumulator = Conversation::empty();
let mut usage_effects = Vec::new();
let mut tool_request_ids = std::collections::HashSet::new();
loop {
tokio::select! {
@ -540,7 +556,10 @@ impl Inference for InferenceRunner<'_> {
return yielded_with(usage_effects);
}
if usage_effects.is_empty() {
let has_recorded_usage = usage_effects
.iter()
.any(|effect| matches!(effect, StateEffect::RecordUsage(_)));
if !has_recorded_usage {
let mut usage = ProviderUsage::new(
self.model_config.model_name.clone(),
goose_providers::conversation::token_usage::Usage::default(),

View file

@ -648,6 +648,8 @@ impl Operation for ToolExecutionOperation<'_> {
if extensions.is_empty() {
return Ok(prompt_parts);
}
// HashMap order shuffles across restarts and would bust the prompt cache.
extensions.sort_by(|a, b| a.name.cmp(&b.name));
let mut lines = vec![
"# Extensions".to_string(),

View file

@ -49,6 +49,35 @@ async fn max_turns_counts_inference_calls_and_injects_budget() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_context_is_persisted_once_per_turn_and_reused_across_inferences() -> Result<()> {
let (pipeline, api) = test_pipeline().await?;
api.on("add one").call(ADD, value(1));
api.on("result: 1").reply("The total is 1");
api.on("hello").reply("hi there!");
let result = pipeline.run(["add one", "hello"]).await?;
let conversation = result.conversation();
let events: Vec<_> = conversation
.messages()
.iter()
.filter(|message| message.is_turn_context())
.collect();
assert_eq!(
events.len(),
2,
"one turn-context event per turn; the turn's second inference reuses it"
);
assert!(events.iter().all(|event| !event.is_user_visible()));
assert!(api
.calls()
.iter()
.all(|call| call.input_contains("<turn-context>")));
Ok(())
}
#[tokio::test]
async fn goal_starts_nudges_and_clears_when_met() -> Result<()> {
let (pipeline, api) = test_pipeline().await?;

View file

@ -970,6 +970,7 @@ impl TestRun {
.conversation()
.messages()
.iter()
.filter(|message| !message.is_turn_context())
.flat_map(|message| {
message
.content

View file

@ -266,7 +266,12 @@ async fn scheduler_is_advertised_only_when_configured_and_manages_jobs() -> Resu
assert_eq!(scheduled_sessions.len(), 1);
assert_eq!(
scheduled_sessions[0].1.message_count,
listed.conversation().messages().len()
listed
.conversation()
.messages()
.iter()
.filter(|message| message.is_user_visible())
.count()
);
api.on("list scheduled runs").call(
MANAGE_SCHEDULE_TOOL_NAME_COMPLETE,

View file

@ -100,9 +100,13 @@ pub async fn compact_messages(
has_text && !has_tool_content
};
let (preserved_user_message, is_most_recent) = if !manual_compact {
// Turn-context events are agent-appended, never the message to preserve.
let (preserved_user_message, preserved_idx, is_most_recent) = if !manual_compact {
let found_msg = messages.iter().enumerate().rev().find_map(|(idx, msg)| {
if !msg.is_agent_visible() || !matches!(msg.role, rmcp::model::Role::User) {
if !msg.is_agent_visible()
|| msg.is_turn_context()
|| !matches!(msg.role, rmcp::model::Role::User)
{
return None;
}
@ -123,13 +127,13 @@ pub async fn compact_messages(
});
if let Some((idx, msg)) = found_msg {
let is_last = idx == messages.len() - 1;
(Some(msg), is_last)
let is_last = messages[idx + 1..].iter().all(Message::is_turn_context);
(Some(msg), Some(idx), is_last)
} else {
(None, false)
(None, None, false)
}
} else {
(None, false)
(None, None, false)
};
let messages_to_compact = messages.as_slice();
@ -175,6 +179,25 @@ pub async fn compact_messages(
final_messages.push(user_msg);
}
// Carry the turn's own context event (it follows the preserved prompt) so
// a mid-turn retry keeps it; anything earlier belongs to a previous turn.
if let Some(carry_from) = preserved_idx.map(|idx| idx + 1) {
if let Some(turn_context) = messages_to_compact[carry_from..]
.iter()
.rev()
.find(|msg| msg.is_turn_context() && msg.is_agent_visible())
{
let mut carried = turn_context.clone();
carried.id = None;
// Storage reloads order by created_timestamp; the copy must keep
// its appended position, not resurface at the original event's time.
if let Some(latest) = final_messages.iter().map(|msg| msg.created).max() {
carried.created = carried.created.max(latest);
}
final_messages.push(carried);
}
}
let conversation = Conversation::new_unvalidated(final_messages);
let retained_context_tokens = match count_context_tokens(&conversation).await {
Ok(tokens) => tokens,
@ -317,8 +340,14 @@ async fn do_compact(
session_id: &str,
messages: &[Message],
) -> Result<(Message, ProviderUsage), anyhow::Error> {
let agent_visible_messages =
Conversation::new_unvalidated(messages.iter().cloned()).agent_visible_messages();
// Keep stale per-turn state out of the summary.
let agent_visible_messages = Conversation::new_unvalidated(
messages
.iter()
.filter(|msg| !msg.is_turn_context())
.cloned(),
)
.agent_visible_messages();
// Try progressively removing more tool response messages from the middle to reduce context length
let removal_percentages = [0, 10, 20, 50, 100];
@ -697,6 +726,7 @@ mod tests {
message: Message,
config: ModelConfig,
max_tool_responses: Option<usize>,
captured_system: std::sync::Mutex<Option<String>>,
}
impl MockProvider {
@ -715,6 +745,7 @@ mod tests {
request_headers: None,
},
max_tool_responses: None,
captured_system: std::sync::Mutex::new(None),
}
}
@ -733,10 +764,11 @@ mod tests {
async fn stream(
&self,
_model_config: &ModelConfig,
_system: &str,
system: &str,
messages: &[Message],
_tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
*self.captured_system.lock().unwrap() = Some(system.to_string());
// If max_tool_responses is set, fail if we have too many
if let Some(max) = self.max_tool_responses {
let tool_response_count = messages
@ -956,6 +988,198 @@ mod tests {
assert!(!user_text.contains("assistant-only preprompt"));
}
#[tokio::test]
async fn preserved_user_message_skips_turn_context_events() {
let conversation = Conversation::new_unvalidated([
Message::user().with_text("earlier request"),
Message::assistant().with_text("earlier response"),
Message::user().with_text("the real current request"),
Message::user()
.with_text("<turn-context>frozen block</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
]);
let provider = MockProvider::new(Message::assistant().with_text("summary"), 1000);
let compacted = compact_messages(
&provider,
&provider.config,
"test-session-id",
&conversation,
false,
)
.await
.unwrap()
.conversation;
let preserved: Vec<_> = compacted
.messages()
.iter()
.filter(|message| message.is_agent_visible() && message.role == Role::User)
.collect();
let turn_context_events: Vec<_> = preserved
.iter()
.filter(|message| message.is_turn_context())
.collect();
let [carried_block] = turn_context_events.as_slice() else {
panic!("expected exactly one carried turn-context event");
};
assert!(
carried_block.as_concat_text().contains("frozen block"),
"the turn's context event must be carried forward for the mid-turn retry"
);
let last = compacted.messages().last().unwrap();
assert!(
last.is_turn_context() && last.is_agent_visible(),
"the carried event must trail the preserved user message"
);
let user_prompt = preserved[preserved.len() - 2];
assert!(
user_prompt
.as_concat_text()
.contains("the real current request"),
"the user's prompt must survive compaction verbatim"
);
assert!(!user_prompt.is_turn_context());
let continuation = compacted
.messages()
.iter()
.find(|message| message.role == Role::Assistant && message.is_agent_visible())
.unwrap()
.as_concat_text();
assert!(
continuation.contains(CONVERSATION_CONTINUATION_TEXT),
"a trailing turn-context event must not demote the compaction to a tool-loop continuation"
);
}
#[tokio::test]
async fn stale_turn_context_from_an_earlier_turn_is_not_carried() {
let conversation = Conversation::new_unvalidated([
Message::user().with_text("earlier request"),
Message::user()
.with_text("<turn-context>stale block</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
Message::assistant().with_text("earlier response"),
Message::user().with_text("the new prompt"),
]);
let provider = MockProvider::new(Message::assistant().with_text("summary"), 1000);
let compacted = compact_messages(
&provider,
&provider.config,
"test-session-id",
&conversation,
false,
)
.await
.unwrap()
.conversation;
assert!(
!compacted
.messages()
.iter()
.any(|message| message.is_agent_visible() && message.is_turn_context()),
"pre-turn compaction must not resurrect a previous turn's context event"
);
let last = compacted.messages().last().unwrap();
assert_eq!(last.as_concat_text(), "the new prompt");
}
#[tokio::test]
async fn carried_turn_context_stays_last_after_persist_and_reload() {
let provider = MockProvider::new(Message::assistant().with_text("summary"), 1000);
let mut prompt = Message::user().with_text("the real current request");
prompt.created -= 3600;
let mut block = Message::user()
.with_text("<turn-context>frozen block</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context());
block.created -= 3600;
let conversation = Conversation::new_unvalidated([prompt, block]);
let compacted = compact_messages(
&provider,
&provider.config,
"test-session-id",
&conversation,
false,
)
.await
.unwrap()
.conversation;
assert!(compacted.messages().last().unwrap().is_turn_context());
let temp_dir = tempfile::tempdir().unwrap();
let manager = crate::session::SessionManager::new(temp_dir.path().to_path_buf());
let session = manager
.create_session(
std::path::PathBuf::from("/tmp/test"),
"carry order".to_string(),
crate::session::session_manager::SessionType::User,
crate::config::GooseMode::default(),
)
.await
.unwrap();
manager
.replace_conversation(&session.id, &compacted)
.await
.unwrap();
let reloaded = manager
.get_session(&session.id, true)
.await
.unwrap()
.conversation
.unwrap();
assert_eq!(reloaded.messages().len(), compacted.messages().len());
let last = reloaded.messages().last().unwrap();
assert!(
last.is_turn_context(),
"the carried event must not resurface at its original timestamp on reload"
);
}
#[tokio::test]
async fn summarizer_input_excludes_turn_context_events() {
let provider = MockProvider::new(Message::assistant().with_text("summary"), 1000);
let turn_context = |text: &str| {
Message::user()
.with_text(text)
.with_metadata(MessageMetadata::agent_only().with_turn_context())
};
let conversation = Conversation::new_unvalidated([
Message::user().with_text("please refactor the parser"),
turn_context("<turn-context>cwd /old/dir</turn-context>"),
Message::assistant().with_text("working on it"),
turn_context("<turn-context>cwd /new/dir</turn-context>"),
]);
let compacted = compact_messages(
&provider,
&provider.config,
"test-session-id",
&conversation,
false,
)
.await
.unwrap()
.conversation;
let system = provider.captured_system.lock().unwrap().clone().unwrap();
assert!(system.contains("please refactor the parser"));
assert!(
!system.contains("/old/dir") && !system.contains("/new/dir"),
"turn-context events must not reach the summarizer as dialogue"
);
let carried = compacted.messages().last().unwrap();
assert!(
carried.is_turn_context() && carried.as_concat_text().contains("/new/dir"),
"the newest turn-context event must still be carried forward"
);
}
#[tokio::test]
async fn tool_pair_summary_projects_nested_audiences_before_provider_input() {
let provider = MockProvider::new(Message::assistant().with_text("summary"), 1000);

View file

@ -1,10 +1,11 @@
use anyhow::Result;
use async_trait::async_trait;
use futures::future::BoxFuture;
use goose_providers::cache_semantics::apply_chat_payload_breakpoints;
use goose_providers::conversation::token_usage::ProviderUsage;
use goose_providers::errors::ProviderError;
use goose_providers::images::ImageFormat;
use serde_json::{json, Value};
use serde_json::Value;
use std::collections::HashMap;
use super::api_client::{ApiClient, AuthMethod};
@ -248,7 +249,7 @@ impl Provider for LiteLLMProvider {
)?;
if self.supports_cache_control(model_config).await {
payload = update_request_for_cache_control(&payload);
apply_chat_payload_breakpoints(&mut payload);
}
let response = self
@ -280,71 +281,6 @@ impl Provider for LiteLLMProvider {
}
}
/// Updates the request payload to include cache control headers for automatic prompt caching
/// Adds ephemeral cache control to the last 2 user messages, system message, and last tool
pub fn update_request_for_cache_control(original_payload: &Value) -> Value {
let mut payload = original_payload.clone();
if let Some(messages_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("messages"))
.and_then(|messages| messages.as_array_mut())
{
let mut user_count = 0;
for message in messages_spec.iter_mut().rev() {
if message.get("role") == Some(&json!("user")) {
if let Some(content) = message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*content = json!([{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]);
}
}
user_count += 1;
if user_count >= 2 {
break;
}
}
}
if let Some(system_message) = messages_spec
.iter_mut()
.find(|msg| msg.get("role") == Some(&json!("system")))
{
if let Some(content) = system_message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*system_message = json!({
"role": "system",
"content": [{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]
});
}
}
}
}
if let Some(tools_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("tools"))
.and_then(|tools| tools.as_array_mut())
{
if let Some(last_tool) = tools_spec.last_mut() {
if let Some(function) = last_tool.get_mut("function") {
function
.as_object_mut()
.unwrap()
.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
}
}
}
payload
}
fn parse_custom_headers(headers_str: String) -> HashMap<String, String> {
let mut headers = HashMap::new();
for line in headers_str.lines() {

View file

@ -11,6 +11,7 @@ use super::openai_compatible::{handle_status, stream_openai_compat};
use super::retry::ProviderRetry;
use crate::conversation::message::Message;
use crate::providers::formats::openrouter as openrouter_format;
use goose_providers::cache_semantics::{apply_chat_payload_breakpoints, CacheSemantics};
use goose_providers::errors::ProviderError;
use goose_providers::formats::openai::create_request;
use goose_providers::model::ModelConfig;
@ -21,7 +22,6 @@ pub const OPENROUTER_PROVIDER_NAME: &str = "openrouter";
const OPENROUTER_PARAMETERS_CONFIG_KEY: &str = "OPENROUTER_PARAMETERS";
pub const OPENROUTER_DEFAULT_MODEL: &str = "anthropic/claude-sonnet-4";
pub const OPENROUTER_DEFAULT_FAST_MODEL: &str = "google/gemini-2.5-flash";
pub const OPENROUTER_MODEL_PREFIX_ANTHROPIC: &str = "anthropic";
// OpenRouter can run many models, we suggest the default
pub const OPENROUTER_KNOWN_MODELS: &[&str] = &[
@ -98,79 +98,6 @@ fn is_mandatory_reasoning_error(error: &ProviderError) -> bool {
matches!(error, ProviderError::RequestFailed(message) if message.contains("Reasoning is mandatory"))
}
/// Update the request when using anthropic model.
/// For anthropic model, we can enable prompt caching to save cost. Since openrouter is the OpenAI compatible
/// endpoint, we need to modify the open ai request to have anthropic cache control field.
fn update_request_for_anthropic(original_payload: &Value) -> Value {
let mut payload = original_payload.clone();
if let Some(messages_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("messages"))
.and_then(|messages| messages.as_array_mut())
{
// Add "cache_control" to the last and second-to-last "user" messages.
// During each turn, we mark the final message with cache_control so the conversation can be
// incrementally cached. The second-to-last user message is also marked for caching with the
// cache_control parameter, so that this checkpoint can read from the previous cache.
let mut user_count = 0;
for message in messages_spec.iter_mut().rev() {
if message.get("role") == Some(&json!("user")) {
if let Some(content) = message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*content = json!([{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]);
}
}
user_count += 1;
if user_count >= 2 {
break;
}
}
}
// Update the system message to have cache_control field.
if let Some(system_message) = messages_spec
.iter_mut()
.find(|msg| msg.get("role") == Some(&json!("system")))
{
if let Some(content) = system_message.get_mut("content") {
if let Some(content_str) = content.as_str() {
*system_message = json!({
"role": "system",
"content": [{
"type": "text",
"text": content_str,
"cache_control": { "type": "ephemeral" }
}]
});
}
}
}
}
if let Some(tools_spec) = payload
.as_object_mut()
.and_then(|obj| obj.get_mut("tools"))
.and_then(|tools| tools.as_array_mut())
{
// Add "cache_control" to the last tool spec, if any. This means that all tool definitions,
// will be cached as a single prefix.
if let Some(last_tool) = tools_spec.last_mut() {
if let Some(function) = last_tool.get_mut("function") {
function
.as_object_mut()
.unwrap()
.insert("cache_control".to_string(), json!({ "type": "ephemeral" }));
}
}
}
payload
}
fn is_gemini_model(model_name: &str) -> bool {
model_name.starts_with("google/")
}
@ -350,8 +277,10 @@ impl Provider for OpenRouterProvider {
}
}
if supports_cache_control(model_config) {
payload = update_request_for_anthropic(&payload);
if CacheSemantics::for_model(OPENROUTER_PROVIDER_NAME, &model_config.model_name)
.uses_explicit_breakpoints()
{
apply_chat_payload_breakpoints(&mut payload);
}
if is_gemini_model(&model_config.model_name) {
@ -386,12 +315,6 @@ impl Provider for OpenRouterProvider {
}
}
fn supports_cache_control(model: &ModelConfig) -> bool {
model
.model_name
.starts_with(OPENROUTER_MODEL_PREFIX_ANTHROPIC)
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -1493,7 +1493,13 @@ mod tests {
.unwrap();
assert_eq!(
sessions[0].message_count,
session.conversation.unwrap().messages().len()
session
.conversation
.unwrap()
.messages()
.iter()
.filter(|m| m.is_user_visible())
.count()
);
}

View file

@ -224,7 +224,7 @@ impl AdversaryInspector {
messages
.iter()
.rev()
.filter(|m| m.role == rmcp::model::Role::User)
.filter(|m| m.role == rmcp::model::Role::User && !m.is_turn_context())
.filter_map(|m| {
let text: String = m
.content
@ -250,7 +250,7 @@ impl AdversaryInspector {
fn extract_original_task(messages: &[Message]) -> String {
for msg in messages {
if msg.role == rmcp::model::Role::User {
if msg.role == rmcp::model::Role::User && !msg.is_turn_context() {
let text: String = msg
.content
.iter()
@ -720,4 +720,39 @@ mod tests {
.unwrap();
assert!(results.is_empty());
}
#[test]
fn user_context_extraction_skips_turn_context_events() {
use crate::conversation::message::MessageMetadata;
let turn_context = |text: &str| {
Message::user()
.with_text(text)
.with_metadata(MessageMetadata::agent_only().with_turn_context())
};
let messages = vec![
turn_context("turn context before any prompt"),
Message::user().with_text("never delete files outside the repo"),
turn_context("turn context for turn one"),
Message::assistant().with_text("understood"),
Message::user().with_text("first task"),
turn_context("turn context for turn two"),
Message::assistant().with_text("done"),
Message::user().with_text("second task"),
turn_context("turn context for turn three"),
];
let recent = AdversaryInspector::extract_recent_user_messages(&messages, 4);
assert_eq!(
recent,
vec![
"never delete files outside the repo",
"first task",
"second task"
]
);
let original = AdversaryInspector::extract_original_task(&messages);
assert_eq!(original, "never delete files outside the repo");
}
}

View file

@ -354,6 +354,7 @@ impl PromptInjectionScanner {
.rev()
.filter(|m| {
crate::conversation::effective_role(m) == crate::conversation::EffectiveRole::User
&& !m.is_turn_context()
})
.take(limit)
.map(|m| {
@ -460,4 +461,33 @@ mod tests {
assert!(result.is_malicious);
}
#[test]
fn extract_user_messages_skips_turn_context_events() {
use crate::conversation::message::MessageMetadata;
let scanner = PromptInjectionScanner::new();
let turn_context = |text: &str| {
Message::user()
.with_text(text)
.with_metadata(MessageMetadata::agent_only().with_turn_context())
};
let messages = vec![
Message::user().with_text("oldest prompt"),
turn_context("turn context one"),
Message::assistant().with_text("ok"),
Message::user().with_text("middle prompt"),
turn_context("turn context two"),
Message::assistant().with_text("done"),
Message::user().with_text("newest prompt"),
turn_context("turn context three"),
];
let extracted = scanner.extract_user_messages(&messages, 3);
assert_eq!(
extracted,
vec!["newest prompt", "middle prompt", "oldest prompt"]
);
}
}

View file

@ -150,6 +150,13 @@ impl<'a> ChatHistorySearch<'a> {
END,
1
) = 1
AND COALESCE(
CASE
WHEN json_valid(m.metadata_json)
THEN json_extract(m.metadata_json, '$.turnContext')
END,
0
) = 0
AND EXISTS (
SELECT 1 FROM json_each(m.content_json) AS content
WHERE json_extract(content.value, '$.type') = 'text'
@ -263,12 +270,29 @@ impl<'a> ChatHistorySearch<'a> {
) -> Result<HashMap<String, usize>> {
let mut session_totals: HashMap<String, usize> = HashMap::new();
for session_id in session_messages.keys() {
let count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM messages WHERE session_id = ?")
.bind(session_id)
.fetch_one(self.pool)
.await
.unwrap_or(0);
let count: i64 = sqlx::query_scalar(
r#"
SELECT COUNT(*) FROM messages m WHERE m.session_id = ?
AND COALESCE(
CASE
WHEN json_valid(m.metadata_json)
THEN json_extract(m.metadata_json, '$.agentVisible')
END,
1
) = 1
AND COALESCE(
CASE
WHEN json_valid(m.metadata_json)
THEN json_extract(m.metadata_json, '$.turnContext')
END,
0
) = 0
"#,
)
.bind(session_id)
.fetch_one(self.pool)
.await
.unwrap_or(0);
session_totals.insert(session_id.clone(), count as usize);
}
Ok(session_totals)
@ -443,5 +467,26 @@ mod tests {
.await
.unwrap();
assert_eq!(hidden_only.total_matches, 0);
insert_message(
&pool,
&Message::user()
.with_text("<turn-context>needle in operational context</turn-context>")
.with_metadata(MessageMetadata::agent_only().with_turn_context()),
now,
)
.await;
let needle = ChatHistorySearch::new(&pool, "needle", Some(10), None, None, None, vec![])
.execute()
.await
.unwrap();
assert_eq!(
needle.total_matches, 1,
"turn-context events are operational context, not searchable conversation"
);
assert_eq!(
needle.results[0].total_messages_in_session, 2,
"session totals must not count turn-context or user-only rows"
);
}
}

View file

@ -365,12 +365,14 @@ fn message_keyword_clause(keyword_count: usize) -> String {
.collect::<Vec<_>>()
.join(" OR ");
let visible = user_visible_message_sql("mq.metadata_json");
format!(
r#"
EXISTS (
SELECT 1
FROM messages mq
WHERE mq.session_id = s.id
AND {visible}
AND EXISTS (
SELECT 1
FROM json_each(mq.content_json)
@ -619,7 +621,7 @@ impl SessionManager {
let user_message_count = conversation
.messages()
.iter()
.filter(|m| matches!(m.role, Role::User))
.filter(|m| matches!(m.role, Role::User) && m.is_user_visible())
.count();
let should_generate_name = if provider.manages_own_context() {
@ -714,6 +716,10 @@ fn normalized_message_timestamp_sql(column: &str) -> String {
)
}
fn user_visible_message_sql(column: &str) -> String {
format!("COALESCE(json_extract({column}, '$.userVisible'), 1) != 0")
}
fn session_sort_at(session: &Session) -> DateTime<Utc> {
session.last_message_at.unwrap_or(session.updated_at)
}
@ -1629,7 +1635,11 @@ impl SessionStorage {
if include_messages {
let conv = self.get_conversation(&session.id).await?;
session.message_count = conv.messages().len();
session.message_count = conv
.messages()
.iter()
.filter(|m| m.is_user_visible())
.count();
session.last_message_at = conv
.messages()
.iter()
@ -1638,7 +1648,8 @@ impl SessionStorage {
session.conversation = Some(conv);
} else {
let sql = format!(
"SELECT COUNT(*), MAX({}) FROM messages WHERE session_id = ?",
"SELECT COUNT(*) FILTER (WHERE {}), MAX({}) FROM messages WHERE session_id = ?",
user_visible_message_sql("metadata_json"),
normalized_message_timestamp_sql("created_timestamp")
);
let (count, last_message_timestamp): (i64, Option<i64>) = sqlx::query_as(&sql)
@ -1979,7 +1990,7 @@ impl SessionStorage {
s.schedule_id, s.recipe_json, s.user_recipe_values_json,
s.provider_name, s.model_config_json, s.goose_mode,
s.archived_at, s.project_id, s.parent_session_id,
COUNT(m.id) as message_count,
COUNT(m.id) FILTER (WHERE {}) as message_count,
MAX({}) as last_message_timestamp,
{} as sort_timestamp
FROM sessions s
@ -1990,6 +2001,7 @@ impl SessionStorage {
{}
{}
"#,
user_visible_message_sql("m.metadata_json"),
normalized_message_timestamp,
sort_timestamp_sql,
message_join,
@ -2985,6 +2997,44 @@ mod tests {
assert_eq!(with_messages.last_message_at, Some(expected));
}
#[tokio::test]
async fn test_message_count_excludes_agent_only_messages() {
let temp_dir = TempDir::new().unwrap();
let sm = SessionManager::new(temp_dir.path().to_path_buf());
let session = sm
.create_session(
PathBuf::from("/tmp/test"),
"Hidden events".to_string(),
SessionType::User,
GooseMode::default(),
)
.await
.unwrap();
add_user_message(&sm, &session.id).await;
sm.add_message(
&session.id,
&Message::user()
.with_text("<turn-context>frozen</turn-context>")
.with_visibility(false, true),
)
.await
.unwrap();
sm.add_message(&session.id, &Message::assistant().with_text("hi"))
.await
.unwrap();
let counted = sm.get_session(&session.id, false).await.unwrap();
assert_eq!(counted.message_count, 2);
let with_messages = sm.get_session(&session.id, true).await.unwrap();
assert_eq!(with_messages.message_count, 2);
let listed = sm.list_sessions().await.unwrap();
let listed_session = listed.iter().find(|s| s.id == session.id).unwrap();
assert_eq!(listed_session.message_count, 2);
}
#[tokio::test]
async fn test_truncate_conversation_from_message_keeps_same_second_previous_rows() {
let temp_dir = TempDir::new().unwrap();

View file

@ -75,7 +75,7 @@ fn extract_short_title(text: &str) -> String {
fn get_initial_user_messages(messages: &Conversation) -> Vec<String> {
messages
.iter()
.filter(|m| m.role == rmcp::model::Role::User)
.filter(|m| m.role == rmcp::model::Role::User && m.is_user_visible())
.take(MSG_COUNT_FOR_SESSION_NAME_GENERATION)
.map(|m| {
m.content

View file

@ -1404,7 +1404,11 @@ mod tests {
.messages()
.to_vec();
let user_count = messages.iter().filter(|m| m.role == Role::User).count();
// Turn-context events are excluded; this test is about streaming deltas.
let user_count = messages
.iter()
.filter(|m| m.role == Role::User && m.is_user_visible())
.count();
let asst_count = messages
.iter()
.filter(|m| m.role == Role::Assistant)
@ -1453,7 +1457,10 @@ mod tests {
.messages()
.to_vec();
let user_count2 = messages2.iter().filter(|m| m.role == Role::User).count();
let user_count2 = messages2
.iter()
.filter(|m| m.role == Role::User && m.is_user_visible())
.count();
let asst_count2 = messages2
.iter()
.filter(|m| m.role == Role::Assistant)

View file

@ -320,7 +320,12 @@ fn assert_conversation_compacted(conversation: &Conversation) {
"Message after compaction at index {} should be agent visible",
idx
);
if idx == continuation_end && matches!(msg.role, rmcp::model::Role::User) {
if msg.is_turn_context() {
assert!(
!msg.is_user_visible(),
"Carried turn-context event should be user-invisible"
);
} else if idx == continuation_end && matches!(msg.role, rmcp::model::Role::User) {
assert!(
!msg.is_user_visible(),
"Projected preserved user message should be user-invisible"