mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-12 18:18:10 -10:00
feat(cache): cache-aware prompt diagnostics and wire payload optimization (#1196)
Merge of PR #1196 by wplll. Adds: Cache-aware prompt layering: - PromptBuilder struct separates prompt construction from inspection - System prompt split into named layers with stability classification - Layers classified as static/history/dynamic for cache debugging /cache inspect command: - SHA-256 hashes of each rendered prompt layer - Base static prefix hash vs full request prefix hash - Static prefix stability status across turns - First-divergence tracking from previous request Wire payload optimization: - Tool result budget: large outputs compacted before API request - Tool result dedup: repeated outputs replaced by compact refs - Turn metadata dedup: repeated <turn_meta> blocks deduplicated - Wire-only: local session messages remain unchanged Project context pack: - Deterministic workspace summary injected into stable prefix - Configurable via [context] project_pack = false Cache warmup and improved footer cache display. Thanks to wplll for the contribution.
This commit is contained in:
1 parent
97d79862c2
commit
54ca5718d2
14 files changed
+2031
-44
No files matched your search
@@ -891,6 +891,9 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
|
||||
})
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let total_tokens = usage
|
||||
.and_then(|u| u.get("total_tokens"))
|
||||
.and_then(Value::as_u64);
|
||||
let reasoning_tokens_raw = usage
|
||||
.and_then(|u| u.get("completion_tokens_details"))
|
||||
.and_then(|details| details.get("reasoning_tokens"))
|
||||
@@ -899,6 +902,10 @@ pub(super) fn parse_usage(usage: Option<&Value>) -> Usage {
|
||||
&& let Some(reasoning_tokens) = reasoning_tokens_raw
|
||||
{
|
||||
output_tokens = reasoning_tokens;
|
||||
} else if output_tokens == 0
|
||||
&& let Some(total_tokens) = total_tokens
|
||||
{
|
||||
output_tokens = total_tokens.saturating_sub(input_tokens);
|
||||
}
|
||||
let cached_tokens = usage
|
||||
.and_then(|u| u.get("prompt_tokens_details"))
|
||||
@@ -979,6 +986,16 @@ impl DeepSeekClient {
|
||||
|
||||
mod chat;
|
||||
|
||||
pub(crate) use chat::PromptInspection;
|
||||
|
||||
pub(crate) fn inspect_prompt_for_request(request: &MessageRequest) -> PromptInspection {
|
||||
chat::inspect_prompt_for_request(request)
|
||||
}
|
||||
|
||||
pub(crate) fn build_cache_warmup_request(request: &MessageRequest) -> MessageRequest {
|
||||
chat::build_cache_warmup_request(request)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1370,6 +1387,267 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_builder_keeps_system_first_and_current_user_input_last() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Previous answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: "<turn_meta>\nCurrent local date: 2026-05-08\n</turn_meta>"
|
||||
.to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "Current user question".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Stable mode, project rules, and tool policy".to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let out = build_chat_messages_for_request(&request);
|
||||
|
||||
assert_eq!(out[0].get("role").and_then(Value::as_str), Some("system"));
|
||||
assert_eq!(
|
||||
out[0].get("content").and_then(Value::as_str),
|
||||
Some("Stable mode, project rules, and tool policy")
|
||||
);
|
||||
let last = out.last().expect("latest user message");
|
||||
assert_eq!(last.get("role").and_then(Value::as_str), Some("user"));
|
||||
assert!(
|
||||
last.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|content| content.ends_with("Current user question")),
|
||||
"current-turn user input must be at the tail of the wire prompt: {last:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_inspect_reports_stable_layers_and_dynamic_user_task() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Current task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nRules\n</project_instructions>\n\n## Project Context Pack\n\n<project_context_pack>\n{}\n</project_context_pack>\n\n## Environment\n\n- lang: en"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
|
||||
assert_eq!(inspection.base_static_prefix_hash.len(), 64);
|
||||
assert_eq!(inspection.full_request_prefix_hash.len(), 64);
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Global system prefix"
|
||||
&& layer.stability.label() == "static"
|
||||
&& layer.char_len == "Base policy".chars().count()
|
||||
&& layer.sha256.len() == 64
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Project context" && layer.stability.label() == "static"
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Project context pack" && layer.stability.label() == "static"
|
||||
}));
|
||||
assert!(inspection.layers.iter().any(|layer| {
|
||||
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
|
||||
}));
|
||||
assert!(
|
||||
inspection.layers.last().is_some_and(
|
||||
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prompt_inspect_keeps_static_base_hash_across_different_user_tasks() {
|
||||
fn request_with_user_task(task: &str) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: task.to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n## Environment\n\n- shell: powershell\n\n## Skills\n\n- rust\n\n## Context Management\n\nKeep concise\n\n## Compact\n\nTemplate"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
}
|
||||
}
|
||||
|
||||
let first = inspect_prompt_for_request(&request_with_user_task("First task"));
|
||||
let second = inspect_prompt_for_request(&request_with_user_task("Second task"));
|
||||
let mut changed_history_request = request_with_user_task("Second task");
|
||||
changed_history_request.messages[0] = Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Different prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
};
|
||||
let changed_history = inspect_prompt_for_request(&changed_history_request);
|
||||
|
||||
assert_eq!(
|
||||
first.base_static_prefix_hash,
|
||||
second.base_static_prefix_hash
|
||||
);
|
||||
assert_eq!(
|
||||
first.full_request_prefix_hash, second.full_request_prefix_hash,
|
||||
"full request prefix excludes the final dynamic user task"
|
||||
);
|
||||
assert_ne!(
|
||||
second.full_request_prefix_hash, changed_history.full_request_prefix_hash,
|
||||
"full request prefix can change when session history changes"
|
||||
);
|
||||
assert!(
|
||||
second.layers.last().is_some_and(
|
||||
|layer| layer.name == "User task" && layer.stability.label() == "dynamic"
|
||||
),
|
||||
"current user task must remain the final layer"
|
||||
);
|
||||
assert!(second.layers.iter().any(|layer| {
|
||||
layer.name == "Message #1 assistant" && layer.stability.label() == "history"
|
||||
}));
|
||||
assert!(!second.layers.iter().any(
|
||||
|layer| layer.name.starts_with("Message #") && layer.stability.label() == "static"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_warmup_request_reuses_stable_prefix_and_fixed_user_tail() {
|
||||
let request = MessageRequest {
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
messages: vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Stable prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Dynamic latest user task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
},
|
||||
],
|
||||
max_tokens: 1024,
|
||||
system: Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nStable project rules\n</project_instructions>\n\n## Previous Session Handoff\n\nDynamic handoff"
|
||||
.to_string(),
|
||||
)),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort: Some("max".to_string()),
|
||||
stream: Some(true),
|
||||
temperature: Some(0.7),
|
||||
top_p: None,
|
||||
};
|
||||
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
|
||||
assert_eq!(warmup.max_tokens, 8);
|
||||
assert_eq!(warmup.temperature, Some(0.0));
|
||||
assert_eq!(warmup.reasoning_effort.as_deref(), Some("max"));
|
||||
assert_eq!(warmup.messages.len(), 2);
|
||||
assert_eq!(warmup.messages[0].role, "assistant");
|
||||
assert_eq!(warmup.messages[1].role, "user");
|
||||
assert_eq!(
|
||||
warmup.messages[1].content,
|
||||
vec![ContentBlock::Text {
|
||||
text: "请只回复 OK".to_string(),
|
||||
cache_control: None,
|
||||
}]
|
||||
);
|
||||
|
||||
let wire = build_chat_messages_for_request(&warmup);
|
||||
let system = wire
|
||||
.first()
|
||||
.and_then(|value| value.get("content"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("warmup system prompt");
|
||||
assert!(system.contains("Stable project rules"));
|
||||
assert!(!system.contains("Dynamic handoff"));
|
||||
assert!(
|
||||
!wire
|
||||
.iter()
|
||||
.any(|value| value.to_string().contains("Dynamic latest user task")),
|
||||
"warmup must not include the dynamic latest user task"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_effort_uses_deepseek_top_level_thinking_parameter() {
|
||||
let mut body = json!({});
|
||||
@@ -2042,6 +2320,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_usage_derives_completion_tokens_from_total_tokens_when_needed() {
|
||||
let usage = parse_usage(Some(&json!({
|
||||
"prompt_tokens": 100,
|
||||
"total_tokens": 125,
|
||||
"prompt_cache_hit_tokens": 70,
|
||||
"prompt_cache_miss_tokens": 30
|
||||
})));
|
||||
|
||||
assert_eq!(usage.input_tokens, 100);
|
||||
assert_eq!(usage.output_tokens, 25);
|
||||
assert_eq!(usage.prompt_cache_hit_tokens, Some(70));
|
||||
assert_eq!(usage.prompt_cache_miss_tokens, Some(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_usage_reads_v4_prompt_tokens_details_cached_tokens() {
|
||||
let usage = parse_usage(Some(&json!({
|
||||
|
||||
+925
-26
File diff suppressed because it is too large.
Load diff
@@ -5,9 +5,10 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use super::CommandResult;
|
||||
use crate::client::{PromptInspection, inspect_prompt_for_request};
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::localization::{Locale, MessageId, tr};
|
||||
use crate::models::{ContentBlock, SystemPrompt, context_window_for_model};
|
||||
use crate::models::{ContentBlock, MessageRequest, SystemPrompt, context_window_for_model};
|
||||
use crate::tui::app::{App, AppAction, TurnCacheRecord};
|
||||
use crate::tui::history::HistoryCell;
|
||||
|
||||
@@ -136,9 +137,15 @@ pub fn context(_app: &mut App) -> CommandResult {
|
||||
/// `arg` is parsed as a count override (default 10, capped at the ring size).
|
||||
/// Renders a fixed-width table the user can paste into a bug report.
|
||||
pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let want = arg
|
||||
.and_then(|s| s.trim().parse::<usize>().ok())
|
||||
.unwrap_or(10);
|
||||
let arg = arg.map(str::trim).filter(|s| !s.is_empty());
|
||||
if matches!(arg, Some("inspect")) {
|
||||
return CommandResult::message(format_cache_inspect(app));
|
||||
}
|
||||
if matches!(arg, Some("warmup")) {
|
||||
return CommandResult::action(AppAction::CacheWarmup);
|
||||
}
|
||||
|
||||
let want = arg.and_then(|s| s.parse::<usize>().ok()).unwrap_or(10);
|
||||
let cap = app.session.turn_cache_history.len();
|
||||
let count = want
|
||||
.min(cap)
|
||||
@@ -151,6 +158,150 @@ pub fn cache(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
CommandResult::message(format_cache_history(app, count, app.ui_locale))
|
||||
}
|
||||
|
||||
fn format_cache_inspect(app: &mut App) -> String {
|
||||
let reasoning_effort = if app.reasoning_effort == crate::tui::app::ReasoningEffort::Auto {
|
||||
app.last_effective_reasoning_effort
|
||||
.and_then(crate::tui::app::ReasoningEffort::api_value)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
app.reasoning_effort.api_value().map(str::to_string)
|
||||
};
|
||||
let request = MessageRequest {
|
||||
model: app.model.clone(),
|
||||
messages: app.api_messages.clone(),
|
||||
max_tokens: 0,
|
||||
system: app.system_prompt.clone(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort,
|
||||
stream: Some(true),
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
let inspection = inspect_prompt_for_request(&request);
|
||||
let previous = app.session.last_cache_inspection.as_ref();
|
||||
|
||||
let mut out = String::new();
|
||||
out.push_str("Cache Inspect\n");
|
||||
out.push_str("Full prompt text is not printed. Hashes are SHA-256 of each rendered layer.\n");
|
||||
out.push_str(&format!(
|
||||
"Base static prefix hash: {}\n",
|
||||
inspection.base_static_prefix_hash
|
||||
));
|
||||
out.push_str(&format!(
|
||||
"Full request prefix hash: {}\n",
|
||||
inspection.full_request_prefix_hash
|
||||
));
|
||||
out.push_str(&format_static_prefix_status(previous, &inspection));
|
||||
out.push_str(&format_first_divergence(previous, &inspection));
|
||||
out.push('\n');
|
||||
|
||||
for layer in &inspection.layers {
|
||||
let mut line = format!(
|
||||
"{}: {}, chars={}, hash={}\n",
|
||||
layer.name,
|
||||
layer.stability.label(),
|
||||
layer.char_len,
|
||||
layer.sha256
|
||||
);
|
||||
if let Some(tool_result) = &layer.tool_result {
|
||||
let trimmed = line.trim_end_matches('\n').to_string();
|
||||
line = format!(
|
||||
"{trimmed}, original_chars={}, sent_chars={}, truncated={}, deduplicated={}\n",
|
||||
tool_result.original_chars,
|
||||
tool_result.sent_chars,
|
||||
tool_result.truncated,
|
||||
tool_result.deduplicated
|
||||
);
|
||||
}
|
||||
if let Some(turn_meta) = &layer.turn_meta {
|
||||
let trimmed = line.trim_end_matches('\n').to_string();
|
||||
line = format!(
|
||||
"{trimmed}, turn_meta_original_chars={}, turn_meta_sent_chars={}, turn_meta_deduplicated={}, turn_meta_sha256={}\n",
|
||||
turn_meta.original_chars,
|
||||
turn_meta.sent_chars,
|
||||
turn_meta.deduplicated,
|
||||
turn_meta.sha256
|
||||
);
|
||||
}
|
||||
out.push_str(&line);
|
||||
}
|
||||
app.session.last_cache_inspection = Some(inspection);
|
||||
out
|
||||
}
|
||||
|
||||
fn format_static_prefix_status(
|
||||
previous: Option<&PromptInspection>,
|
||||
current: &PromptInspection,
|
||||
) -> String {
|
||||
let Some(previous) = previous else {
|
||||
return "Static base prefix stability: no previous request\n".to_string();
|
||||
};
|
||||
if previous.base_static_prefix_hash == current.base_static_prefix_hash {
|
||||
return "Static base prefix stability: OK\n".to_string();
|
||||
}
|
||||
|
||||
let changed = changed_static_layers(previous, current);
|
||||
if changed.is_empty() {
|
||||
"Static base prefix stability: WARNING (base hash changed)\n".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Static base prefix stability: WARNING changed layers: {}\n",
|
||||
changed.join(", ")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn format_first_divergence(
|
||||
previous: Option<&PromptInspection>,
|
||||
current: &PromptInspection,
|
||||
) -> String {
|
||||
let Some(previous) = previous else {
|
||||
return "First divergence from previous request: unavailable\n".to_string();
|
||||
};
|
||||
let max_len = previous.layers.len().max(current.layers.len());
|
||||
for index in 0..max_len {
|
||||
match (previous.layers.get(index), current.layers.get(index)) {
|
||||
(Some(prev), Some(curr)) if prev.name == curr.name && prev.sha256 == curr.sha256 => {}
|
||||
(Some(prev), Some(curr)) if prev.name == curr.name => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(Some(_), Some(curr)) => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(None, Some(curr)) => {
|
||||
return format!("First divergence from previous request: {}\n", curr.name);
|
||||
}
|
||||
(Some(prev), None) => {
|
||||
return format!(
|
||||
"First divergence from previous request: {} removed\n",
|
||||
prev.name
|
||||
);
|
||||
}
|
||||
(None, None) => break,
|
||||
}
|
||||
}
|
||||
"First divergence from previous request: none\n".to_string()
|
||||
}
|
||||
|
||||
fn changed_static_layers(previous: &PromptInspection, current: &PromptInspection) -> Vec<String> {
|
||||
current
|
||||
.layers
|
||||
.iter()
|
||||
.filter(|layer| layer.stability.label() == "static")
|
||||
.filter(|layer| {
|
||||
previous
|
||||
.layers
|
||||
.iter()
|
||||
.find(|previous_layer| previous_layer.name == layer.name)
|
||||
.is_none_or(|previous_layer| previous_layer.sha256 != layer.sha256)
|
||||
})
|
||||
.map(|layer| layer.name.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_cache_history(app: &App, count: usize, locale: Locale) -> String {
|
||||
let total = app.session.turn_cache_history.len();
|
||||
let start = total.saturating_sub(count);
|
||||
@@ -416,6 +567,171 @@ mod tests {
|
||||
assert!(msg.contains("no turns recorded yet"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_reports_hashes_without_prompt_text() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text(
|
||||
"Base policy\n\n<project_instructions source=\"AGENTS.md\">\nSECRET_PROJECT_RULE\n</project_instructions>"
|
||||
.to_string(),
|
||||
));
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "SECRET_USER_TASK".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("Cache Inspect"));
|
||||
assert!(msg.contains("Base static prefix hash:"));
|
||||
assert!(msg.contains("Full request prefix hash:"));
|
||||
assert!(msg.contains("Static base prefix stability: no previous request"));
|
||||
assert!(msg.contains("First divergence from previous request: unavailable"));
|
||||
assert!(msg.contains("Global system prefix: static"));
|
||||
assert!(msg.contains("Project context: static"));
|
||||
assert!(msg.contains("User task: dynamic"));
|
||||
assert!(!msg.contains("SECRET_PROJECT_RULE"));
|
||||
assert!(!msg.contains("SECRET_USER_TASK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_reports_divergence_from_previous_request() {
|
||||
let mut app = create_test_app();
|
||||
app.system_prompt = Some(SystemPrompt::Text(
|
||||
"Base policy\n\n## Environment\n\n- shell: powershell".to_string(),
|
||||
));
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "Prior answer".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![crate::models::ContentBlock::Text {
|
||||
text: "First task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let first = cache(&mut app, Some("inspect"))
|
||||
.message
|
||||
.expect("first inspect output");
|
||||
assert!(first.contains("Static base prefix stability: no previous request"));
|
||||
|
||||
if let Some(last) = app.api_messages.last_mut()
|
||||
&& let Some(crate::models::ContentBlock::Text { text, .. }) = last.content.first_mut()
|
||||
{
|
||||
*text = "Second task".to_string();
|
||||
}
|
||||
|
||||
let second = cache(&mut app, Some("inspect"))
|
||||
.message
|
||||
.expect("second inspect output");
|
||||
assert!(second.contains("Static base prefix stability: OK"));
|
||||
assert!(second.contains("First divergence from previous request: User task"));
|
||||
assert!(second.contains("Message #1 assistant: history"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_displays_tool_result_budget_metadata() {
|
||||
let mut app = create_test_app();
|
||||
let long_output = format!("{}{}", "A".repeat(7_000), "Z".repeat(7_000));
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "shell_command".to_string(),
|
||||
input: serde_json::json!({"command": "cargo test"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: long_output.clone(),
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-2".to_string(),
|
||||
name: "shell_command".to_string(),
|
||||
input: serde_json::json!({"command": "cargo test"}),
|
||||
caller: None,
|
||||
}],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-2".to_string(),
|
||||
content: long_output,
|
||||
is_error: None,
|
||||
content_blocks: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("original_chars=14000"), "got: {msg}");
|
||||
assert!(msg.contains("truncated=true"), "got: {msg}");
|
||||
assert!(msg.contains("deduplicated=false"), "got: {msg}");
|
||||
assert!(msg.contains("deduplicated=true"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_displays_turn_meta_dedup_metadata() {
|
||||
let mut app = create_test_app();
|
||||
let turn_meta = format!(
|
||||
"<turn_meta>\nCurrent local date: 2026-05-09\n{}\n</turn_meta>",
|
||||
"Working set: src/lib.rs\n".repeat(20)
|
||||
);
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: turn_meta.clone(),
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "first task".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
app.api_messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
ContentBlock::Text {
|
||||
text: turn_meta,
|
||||
cache_control: None,
|
||||
},
|
||||
ContentBlock::Text {
|
||||
text: "second task".to_string(),
|
||||
cache_control: None,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let result = cache(&mut app, Some("inspect"));
|
||||
let msg = result.message.expect("inspect output");
|
||||
|
||||
assert!(msg.contains("turn_meta_original_chars="), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_sent_chars="), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_deduplicated=false"), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_deduplicated=true"), "got: {msg}");
|
||||
assert!(msg.contains("turn_meta_sha256="), "got: {msg}");
|
||||
assert!(!msg.contains("Working set: src/lib.rs"), "got: {msg}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_command_renders_recorded_turns_with_ratio() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -488,7 +488,7 @@ pub const COMMANDS: &[CommandInfo] = &[
|
||||
CommandInfo {
|
||||
name: "cache",
|
||||
aliases: &[],
|
||||
usage: "/cache [count]",
|
||||
usage: "/cache [count|inspect|warmup]",
|
||||
description_id: MessageId::CmdCacheDescription,
|
||||
},
|
||||
];
|
||||
@@ -927,6 +927,25 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_inspect_dispatches_through_cache_command() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/cache inspect", &mut app);
|
||||
let msg = result.message.expect("cache inspect should return text");
|
||||
assert!(msg.contains("Cache Inspect"));
|
||||
assert!(msg.contains("Base static prefix hash:"));
|
||||
assert!(msg.contains("Full request prefix hash:"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_warmup_dispatches_action() {
|
||||
let mut app = create_test_app();
|
||||
let result = execute("/cache warmup", &mut app);
|
||||
assert!(result.message.is_none());
|
||||
assert!(matches!(result.action, Some(AppAction::CacheWarmup)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_config_opens_config_view_action() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -690,6 +690,10 @@ pub struct ContextConfig {
|
||||
/// v0.7.5 audits V4 prefix-cache behavior.
|
||||
#[serde(default)]
|
||||
pub enabled: Option<bool>,
|
||||
/// Include a deterministic project context pack in the stable prompt
|
||||
/// prefix. Default: true; set `[context] project_pack = false` to disable.
|
||||
#[serde(default)]
|
||||
pub project_pack: Option<bool>,
|
||||
/// Verbatim window: last N turns never summarized. Default: 16.
|
||||
#[serde(default)]
|
||||
pub verbatim_window_turns: Option<usize>,
|
||||
@@ -1499,6 +1503,11 @@ impl Config {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn project_context_pack_enabled(&self) -> bool {
|
||||
self.context.project_pack.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Return whether shell execution is allowed.
|
||||
#[must_use]
|
||||
pub fn allow_shell(&self) -> bool {
|
||||
@@ -2444,6 +2453,10 @@ fn merge_config(base: Config, override_cfg: Config) -> Config {
|
||||
lsp: override_cfg.lsp.or(base.lsp),
|
||||
context: ContextConfig {
|
||||
enabled: override_cfg.context.enabled.or(base.context.enabled),
|
||||
project_pack: override_cfg
|
||||
.context
|
||||
.project_pack
|
||||
.or(base.context.project_pack),
|
||||
verbatim_window_turns: override_cfg
|
||||
.context
|
||||
.verbatim_window_turns
|
||||
@@ -4104,6 +4117,15 @@ api_key = "old-openrouter-key"
|
||||
assert_eq!(merged.context.enabled, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_defaults_on_and_can_be_disabled() {
|
||||
let mut config = Config::default();
|
||||
assert!(config.project_context_pack_enabled());
|
||||
|
||||
config.context.project_pack = Some(false);
|
||||
assert!(!config.project_context_pack_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_accepts_future_deepseek_model_id() -> Result<()> {
|
||||
let config = Config {
|
||||
|
||||
@@ -95,6 +95,7 @@ pub struct EngineConfig {
|
||||
/// `instructions = [...]` config (or the per-project override).
|
||||
/// Resolved via `expand_path` so `~` works.
|
||||
pub instructions: Vec<PathBuf>,
|
||||
pub project_context_pack_enabled: bool,
|
||||
/// Maximum number of assistant steps before stopping.
|
||||
pub max_steps: u32,
|
||||
/// Maximum number of concurrently active subagents.
|
||||
@@ -166,6 +167,7 @@ impl Default for EngineConfig {
|
||||
mcp_config_path: PathBuf::from("mcp.json"),
|
||||
skills_dir: crate::skills::default_skills_dir(),
|
||||
instructions: Vec::new(),
|
||||
project_context_pack_enabled: true,
|
||||
max_steps: 100,
|
||||
max_subagents: DEFAULT_MAX_SUBAGENTS,
|
||||
features: Features::with_defaults(),
|
||||
@@ -442,6 +444,7 @@ impl Engine {
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: config.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled,
|
||||
locale_tag: &config.locale_tag,
|
||||
},
|
||||
session.approval_mode,
|
||||
@@ -1862,6 +1865,7 @@ impl Engine {
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: user_memory_block.as_deref(),
|
||||
goal_objective: self.config.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: self.config.project_context_pack_enabled,
|
||||
locale_tag: &self.config.locale_tag,
|
||||
},
|
||||
self.session.approval_mode,
|
||||
|
||||
@@ -829,6 +829,33 @@ fn turn_metadata_includes_current_local_date_without_working_set() {
|
||||
assert!(text.contains(&format!("Current local date: {today}")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_text_message_keeps_current_turn_input_after_turn_metadata() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let config = EngineConfig {
|
||||
workspace: tmp.path().to_path_buf(),
|
||||
..Default::default()
|
||||
};
|
||||
let (engine, _handle) = Engine::new(config, &Config::default());
|
||||
|
||||
let user_msg =
|
||||
engine.user_text_message_with_turn_metadata("explain the cache metrics".to_string());
|
||||
|
||||
let last_text = user_msg
|
||||
.content
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|block| {
|
||||
if let ContentBlock::Text { text, .. } = block {
|
||||
Some(text.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.expect("user text block");
|
||||
assert_eq!(last_text, "explain the cache metrics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn messages_with_turn_metadata_preserves_stored_messages_for_prefix_cache() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -4186,6 +4186,7 @@ async fn run_exec_agent(
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: config.skills_dir(),
|
||||
instructions: config.instructions_paths(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
max_steps: 100,
|
||||
max_subagents,
|
||||
features: config.features(),
|
||||
|
||||
@@ -11,9 +11,11 @@
|
||||
//! The loaded content is injected into the system prompt to give the agent
|
||||
//! context about the project's conventions, structure, and requirements.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
/// Names of project context files to look for, in priority order.
|
||||
@@ -26,6 +28,25 @@ const PROJECT_CONTEXT_FILES: &[&str] = &[
|
||||
|
||||
/// Maximum size for project context files (to prevent loading huge files)
|
||||
const MAX_CONTEXT_SIZE: usize = 100 * 1024; // 100KB
|
||||
const PACK_README_MAX_CHARS: usize = 4_000;
|
||||
const PACK_MAX_ENTRIES: usize = 400;
|
||||
const PACK_MAX_SOURCE_FILES: usize = 80;
|
||||
const PACK_MAX_CONFIG_FILES: usize = 80;
|
||||
const PACK_MAX_DEPTH: usize = 4;
|
||||
const PACK_IGNORED_DIRS: &[&str] = &[
|
||||
".git",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
"dist",
|
||||
"build",
|
||||
"target",
|
||||
".idea",
|
||||
".vscode",
|
||||
".pytest_cache",
|
||||
".DS_Store",
|
||||
];
|
||||
|
||||
// === Errors ===
|
||||
|
||||
@@ -99,6 +120,203 @@ impl ProjectContext {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ProjectContextPack {
|
||||
project_name: String,
|
||||
directory_structure: Vec<String>,
|
||||
readme: Option<ReadmePack>,
|
||||
config_files: Vec<String>,
|
||||
key_source_files: Vec<String>,
|
||||
counts: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ReadmePack {
|
||||
path: String,
|
||||
excerpt: String,
|
||||
}
|
||||
|
||||
/// Generate a deterministic, cache-friendly project context pack.
|
||||
///
|
||||
/// The pack intentionally uses only stable workspace facts: relative paths,
|
||||
/// sorted entries, bounded README text, and sorted JSON object fields. It does
|
||||
/// not include timestamps, random ids, absolute temp paths, or live git state.
|
||||
pub fn generate_project_context_pack(workspace: &Path) -> Option<String> {
|
||||
let mut entries = Vec::new();
|
||||
collect_pack_entries(workspace, workspace, 0, &mut entries);
|
||||
entries.sort();
|
||||
entries.truncate(PACK_MAX_ENTRIES);
|
||||
|
||||
let mut config_files = entries
|
||||
.iter()
|
||||
.filter(|path| is_config_file(path))
|
||||
.take(PACK_MAX_CONFIG_FILES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
config_files.sort();
|
||||
|
||||
let mut key_source_files = entries
|
||||
.iter()
|
||||
.filter(|path| is_source_file(path))
|
||||
.take(PACK_MAX_SOURCE_FILES)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
key_source_files.sort();
|
||||
|
||||
let readme = read_readme_excerpt(workspace, &entries);
|
||||
let mut counts = BTreeMap::new();
|
||||
counts.insert("config_files".to_string(), config_files.len());
|
||||
counts.insert("directory_entries".to_string(), entries.len());
|
||||
counts.insert("key_source_files".to_string(), key_source_files.len());
|
||||
|
||||
let pack = ProjectContextPack {
|
||||
project_name: workspace
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("workspace")
|
||||
.to_string(),
|
||||
directory_structure: entries,
|
||||
readme,
|
||||
config_files,
|
||||
key_source_files,
|
||||
counts,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string_pretty(&pack).ok()?;
|
||||
Some(format!(
|
||||
"## Project Context Pack\n\n<project_context_pack>\n{json}\n</project_context_pack>"
|
||||
))
|
||||
}
|
||||
|
||||
fn collect_pack_entries(root: &Path, dir: &Path, depth: usize, out: &mut Vec<String>) {
|
||||
if depth > PACK_MAX_DEPTH || out.len() >= PACK_MAX_ENTRIES {
|
||||
return;
|
||||
}
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut children = read_dir.filter_map(Result::ok).collect::<Vec<_>>();
|
||||
children.sort_by_key(|entry| entry.path());
|
||||
|
||||
for entry in children {
|
||||
if out.len() >= PACK_MAX_ENTRIES {
|
||||
break;
|
||||
}
|
||||
let path = entry.path();
|
||||
let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
if file_type.is_dir() && PACK_IGNORED_DIRS.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(relative) = relative_slash_path(root, &path) {
|
||||
if file_type.is_dir() {
|
||||
out.push(format!("{relative}/"));
|
||||
collect_pack_entries(root, &path, depth + 1, out);
|
||||
} else if file_type.is_file() {
|
||||
out.push(relative);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_slash_path(root: &Path, path: &Path) -> Option<String> {
|
||||
let relative = path.strip_prefix(root).ok()?;
|
||||
let mut parts = Vec::new();
|
||||
for component in relative.components() {
|
||||
parts.push(component.as_os_str().to_string_lossy().to_string());
|
||||
}
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(parts.join("/"))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_readme_excerpt(workspace: &Path, entries: &[String]) -> Option<ReadmePack> {
|
||||
let path = entries
|
||||
.iter()
|
||||
.find(|path| {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
lower == "readme.md" || lower == "readme.txt" || lower == "readme"
|
||||
})?
|
||||
.clone();
|
||||
let raw = fs::read_to_string(workspace.join(&path)).ok()?;
|
||||
let excerpt = truncate_chars(raw.trim(), PACK_README_MAX_CHARS);
|
||||
if excerpt.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(ReadmePack { path, excerpt })
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_chars(value: &str, max_chars: usize) -> String {
|
||||
if value.chars().count() <= max_chars {
|
||||
return value.to_string();
|
||||
}
|
||||
value.chars().take(max_chars).collect::<String>()
|
||||
}
|
||||
|
||||
fn is_config_file(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
let name = lower.rsplit('/').next().unwrap_or(lower.as_str());
|
||||
matches!(
|
||||
name,
|
||||
"cargo.toml"
|
||||
| "package.json"
|
||||
| "tsconfig.json"
|
||||
| "pyproject.toml"
|
||||
| "requirements.txt"
|
||||
| "go.mod"
|
||||
| "config.toml"
|
||||
| "deepseek.toml"
|
||||
| "dockerfile"
|
||||
| "compose.yaml"
|
||||
| "compose.yml"
|
||||
| "docker-compose.yaml"
|
||||
| "docker-compose.yml"
|
||||
| "makefile"
|
||||
) || lower.ends_with(".config.js")
|
||||
|| lower.ends_with(".config.ts")
|
||||
|| lower.ends_with(".toml")
|
||||
|| lower.ends_with(".yaml")
|
||||
|| lower.ends_with(".yml")
|
||||
}
|
||||
|
||||
fn is_source_file(path: &str) -> bool {
|
||||
let lower = path.to_ascii_lowercase();
|
||||
matches!(
|
||||
lower.rsplit('.').next(),
|
||||
Some(
|
||||
"rs" | "py"
|
||||
| "js"
|
||||
| "jsx"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "go"
|
||||
| "java"
|
||||
| "kt"
|
||||
| "c"
|
||||
| "cc"
|
||||
| "cpp"
|
||||
| "h"
|
||||
| "hpp"
|
||||
| "cs"
|
||||
| "rb"
|
||||
| "php"
|
||||
| "swift"
|
||||
| "sql"
|
||||
| "sh"
|
||||
| "bash"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/// Load project context from the workspace directory.
|
||||
///
|
||||
/// This searches for known project context files and loads the first one found.
|
||||
@@ -528,4 +746,36 @@ mod tests {
|
||||
.contains("Organization instructions")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_is_stable_and_sorted() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("README.md"), "# Demo\n\nReadme body").expect("write");
|
||||
fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"demo\"").expect("write");
|
||||
fs::create_dir_all(tmp.path().join("src")).expect("mkdir src");
|
||||
fs::write(tmp.path().join("src").join("z.rs"), "mod z;").expect("write z");
|
||||
fs::write(tmp.path().join("src").join("a.rs"), "mod a;").expect("write a");
|
||||
fs::create_dir_all(tmp.path().join("node_modules").join("pkg")).expect("mkdir ignored");
|
||||
fs::write(
|
||||
tmp.path().join("node_modules").join("pkg").join("index.js"),
|
||||
"ignored",
|
||||
)
|
||||
.expect("write ignored");
|
||||
|
||||
let first = generate_project_context_pack(tmp.path()).expect("pack");
|
||||
let second = generate_project_context_pack(tmp.path()).expect("pack again");
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert!(first.contains("\"project_name\""));
|
||||
assert!(first.contains("\"directory_structure\""));
|
||||
assert!(first.contains("\"README.md\""));
|
||||
assert!(first.contains("\"Cargo.toml\""));
|
||||
assert!(first.contains("\"src/a.rs\""));
|
||||
assert!(first.contains("\"src/z.rs\""));
|
||||
assert!(!first.contains("node_modules"));
|
||||
assert!(
|
||||
first.find("\"src/a.rs\"").expect("a before z")
|
||||
< first.find("\"src/z.rs\"").expect("z")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ use std::path::{Path, PathBuf};
|
||||
pub struct PromptSessionContext<'a> {
|
||||
pub user_memory_block: Option<&'a str>,
|
||||
pub goal_objective: Option<&'a str>,
|
||||
pub project_context_pack_enabled: bool,
|
||||
/// Resolved BCP-47 locale tag for the `## Environment` block in
|
||||
/// the system prompt (e.g. `"en"`, `"zh-Hans"`, `"ja"`). The
|
||||
/// caller is responsible for resolving this from `Settings`; no
|
||||
@@ -333,6 +334,7 @@ pub fn system_prompt_for_mode_with_context_and_skills(
|
||||
PromptSessionContext {
|
||||
user_memory_block,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
)
|
||||
@@ -383,6 +385,12 @@ pub fn system_prompt_for_mode_with_context_skills_session_and_approval(
|
||||
mode_prompt
|
||||
};
|
||||
|
||||
if session_context.project_context_pack_enabled
|
||||
&& let Some(pack) = crate::project_context::generate_project_context_pack(workspace)
|
||||
{
|
||||
full_prompt = format!("{full_prompt}\n\n{pack}");
|
||||
}
|
||||
|
||||
// 2.25. Environment block — locale, platform, shell, pwd. All
|
||||
// four inputs are session-stable (workspace path is fixed for
|
||||
// the run; locale is loaded once by the caller; platform/shell
|
||||
@@ -545,6 +553,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "ja",
|
||||
},
|
||||
) {
|
||||
@@ -556,6 +565,59 @@ mod tests {
|
||||
assert!(prompt.contains("- deepseek_version:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_can_be_disabled() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: false,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(!prompt.contains("<project_context_pack>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_context_pack_is_before_dynamic_tail() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join("README.md"), "# Pack test").expect("write readme");
|
||||
std::fs::create_dir_all(tmp.path().join(".deepseek")).expect("mkdir");
|
||||
std::fs::write(tmp.path().join(".deepseek").join("handoff.md"), "handoff")
|
||||
.expect("handoff");
|
||||
let prompt = match system_prompt_for_mode_with_context_skills_and_session(
|
||||
AppMode::Agent,
|
||||
tmp.path(),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: None,
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
SystemPrompt::Text(text) => text,
|
||||
SystemPrompt::Blocks(_) => panic!("expected text system prompt"),
|
||||
};
|
||||
assert!(prompt.contains("<project_context_pack>"));
|
||||
assert!(
|
||||
prompt.find("<project_context_pack>").expect("pack")
|
||||
< prompt.find("## Previous Session Handoff").expect("handoff")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handoff_artifact_is_prepended_to_system_prompt_when_present() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
@@ -716,6 +778,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some("Fix transcript corruption"),
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
@@ -743,6 +806,7 @@ mod tests {
|
||||
PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: Some(" "),
|
||||
project_context_pack_enabled: true,
|
||||
locale_tag: "en",
|
||||
},
|
||||
) {
|
||||
|
||||
@@ -1923,6 +1923,7 @@ impl RuntimeThreadManager {
|
||||
mcp_config_path: self.config.mcp_config_path(),
|
||||
skills_dir: self.config.skills_dir(),
|
||||
instructions: self.config.instructions_paths(),
|
||||
project_context_pack_enabled: self.config.project_context_pack_enabled(),
|
||||
max_steps: 100,
|
||||
max_subagents: self.config.max_subagents().clamp(1, MAX_SUBAGENTS),
|
||||
features: self.config.features(),
|
||||
|
||||
@@ -8,6 +8,7 @@ use ratatui::layout::Rect;
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::client::PromptInspection;
|
||||
use crate::compaction::CompactionConfig;
|
||||
use crate::config::{
|
||||
ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key,
|
||||
@@ -626,6 +627,7 @@ pub struct SessionState {
|
||||
pub total_tokens: u32,
|
||||
pub total_conversation_tokens: u32,
|
||||
pub turn_cache_history: VecDeque<TurnCacheRecord>,
|
||||
pub last_cache_inspection: Option<PromptInspection>,
|
||||
}
|
||||
|
||||
impl Default for SessionState {
|
||||
@@ -646,6 +648,7 @@ impl Default for SessionState {
|
||||
total_tokens: 0,
|
||||
total_conversation_tokens: 0,
|
||||
turn_cache_history: VecDeque::new(),
|
||||
last_cache_inspection: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3818,6 +3821,7 @@ pub enum AppAction {
|
||||
},
|
||||
ListSubAgents,
|
||||
FetchModels,
|
||||
CacheWarmup,
|
||||
/// Switch the active LLM backend (DeepSeek vs NVIDIA NIM) without
|
||||
/// restarting the process. The runtime rebuilds its API client from
|
||||
/// the updated config. `model` overrides the post-switch model
|
||||
|
||||
@@ -30,7 +30,7 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||
|
||||
use crate::audit::log_sensitive_event;
|
||||
use crate::automation_manager::{AutomationManager, AutomationSchedulerConfig, spawn_scheduler};
|
||||
use crate::client::DeepSeekClient;
|
||||
use crate::client::{DeepSeekClient, build_cache_warmup_request};
|
||||
use crate::commands;
|
||||
use crate::compaction::estimate_input_tokens_conservative;
|
||||
use crate::config::{ApiProvider, Config, DEFAULT_NVIDIA_NIM_BASE_URL};
|
||||
@@ -40,7 +40,10 @@ use crate::core::engine::{EngineConfig, EngineHandle, spawn_engine};
|
||||
use crate::core::events::Event as EngineEvent;
|
||||
use crate::core::ops::Op;
|
||||
use crate::hooks::HookEvent;
|
||||
use crate::models::{ContentBlock, Message, SystemPrompt, context_window_for_model};
|
||||
use crate::llm_client::LlmClient;
|
||||
use crate::models::{
|
||||
ContentBlock, Message, MessageRequest, SystemPrompt, Usage, context_window_for_model,
|
||||
};
|
||||
use crate::palette;
|
||||
use crate::prompts;
|
||||
use crate::session_manager::{
|
||||
@@ -510,6 +513,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
|
||||
mcp_config_path: config.mcp_config_path(),
|
||||
skills_dir: app.skills_dir.clone(),
|
||||
instructions: config.instructions_paths(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
// Effectively unlimited. V4 has a 1M context window and the user
|
||||
// wants the model running until it's actually done. The previous cap
|
||||
// of 100 hit the ceiling on long multi-step plans (wide refactors,
|
||||
@@ -2981,6 +2985,50 @@ async fn fetch_available_models(config: &Config) -> Result<Vec<String>> {
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn run_cache_warmup(app: &App, config: &Config) -> Result<Usage> {
|
||||
let client = DeepSeekClient::new(config)?;
|
||||
let reasoning_effort = if app.reasoning_effort == ReasoningEffort::Auto {
|
||||
app.last_effective_reasoning_effort
|
||||
.and_then(ReasoningEffort::api_value)
|
||||
.map(str::to_string)
|
||||
} else {
|
||||
app.reasoning_effort.api_value().map(str::to_string)
|
||||
};
|
||||
let request = MessageRequest {
|
||||
model: app.model.clone(),
|
||||
messages: app.api_messages.clone(),
|
||||
max_tokens: 1024,
|
||||
system: app.system_prompt.clone(),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
metadata: None,
|
||||
thinking: None,
|
||||
reasoning_effort,
|
||||
stream: None,
|
||||
temperature: None,
|
||||
top_p: None,
|
||||
};
|
||||
let warmup = build_cache_warmup_request(&request);
|
||||
let response =
|
||||
tokio::time::timeout(Duration::from_secs(45), client.create_message(warmup)).await??;
|
||||
Ok(response.usage)
|
||||
}
|
||||
|
||||
fn format_cache_warmup_result(usage: &Usage) -> String {
|
||||
let cache = match (
|
||||
usage.prompt_cache_hit_tokens,
|
||||
usage.prompt_cache_miss_tokens,
|
||||
) {
|
||||
(Some(hit), Some(miss)) => format!("Cache warmup complete: hit {hit} | miss {miss}"),
|
||||
(Some(hit), None) => format!("Cache warmup complete: hit {hit} | miss unavailable"),
|
||||
(None, Some(miss)) => format!("Cache warmup complete: hit unavailable | miss {miss}"),
|
||||
(None, None) => "Cache warmup complete: cache telemetry unavailable".to_string(),
|
||||
};
|
||||
format!(
|
||||
"{cache}\nNote: the first warmup is usually a miss. Later requests that reuse the same stable prefix may hit the provider cache; a hit is not guaranteed."
|
||||
)
|
||||
}
|
||||
|
||||
fn format_available_models_message(current_model: &str, models: &[String]) -> String {
|
||||
let mut lines = vec![format!("Available models ({})", models.len())];
|
||||
for model in models {
|
||||
@@ -3739,6 +3787,7 @@ async fn dispatch_user_message(
|
||||
prompts::PromptSessionContext {
|
||||
user_memory_block: None,
|
||||
goal_objective: app.goal.goal_objective.as_deref(),
|
||||
project_context_pack_enabled: config.project_context_pack_enabled(),
|
||||
locale_tag: app.ui_locale.tag(),
|
||||
},
|
||||
),
|
||||
@@ -4556,6 +4605,24 @@ async fn apply_command_result(
|
||||
}
|
||||
}
|
||||
}
|
||||
AppAction::CacheWarmup => {
|
||||
app.status_message = Some("Warming DeepSeek cache...".to_string());
|
||||
match run_cache_warmup(app, config).await {
|
||||
Ok(usage) => {
|
||||
let message = format_cache_warmup_result(&usage);
|
||||
app.add_message(HistoryCell::System {
|
||||
content: message.clone(),
|
||||
});
|
||||
app.status_message = Some("Cache warmup complete".to_string());
|
||||
}
|
||||
Err(error) => {
|
||||
app.add_message(HistoryCell::System {
|
||||
content: format!("Cache warmup failed: {error}"),
|
||||
});
|
||||
app.status_message = Some("Cache warmup failed".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
AppAction::SwitchProvider { provider, model } => {
|
||||
switch_provider(app, engine_handle, config, provider, model).await;
|
||||
}
|
||||
@@ -7002,9 +7069,15 @@ fn footer_coherence_spans(app: &App) -> Vec<Span<'static>> {
|
||||
}
|
||||
|
||||
fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
|
||||
if app.session.last_prompt_tokens.is_none() && app.session.last_completion_tokens.is_none() {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(hit_tokens) = app.session.last_prompt_cache_hit_tokens else {
|
||||
return vec![Span::styled(
|
||||
"Cache: unavailable",
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)];
|
||||
};
|
||||
let miss_tokens = app
|
||||
.session
|
||||
.last_prompt_cache_miss_tokens
|
||||
@@ -7015,11 +7088,11 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
.saturating_sub(hit_tokens)
|
||||
});
|
||||
let total = hit_tokens.saturating_add(miss_tokens);
|
||||
if total == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let percent = (f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0);
|
||||
let percent = if total == 0 {
|
||||
0.0
|
||||
} else {
|
||||
(f64::from(hit_tokens) / f64::from(total) * 100.0).clamp(0.0, 100.0)
|
||||
};
|
||||
// Threshold-based coloring for cache hit rate (#396):
|
||||
// >80%: green (good cache utilization)
|
||||
// 40-80%: yellow/warning
|
||||
@@ -7032,7 +7105,10 @@ fn footer_cache_spans(app: &App) -> Vec<Span<'static>> {
|
||||
palette::STATUS_ERROR
|
||||
};
|
||||
vec![Span::styled(
|
||||
format!("cache hit {:.0}%", percent),
|
||||
format!(
|
||||
"Cache: {:.1}% hit | hit {hit_tokens} | miss {miss_tokens}",
|
||||
percent
|
||||
),
|
||||
Style::default().fg(color),
|
||||
)]
|
||||
}
|
||||
|
||||
@@ -1483,11 +1483,22 @@ fn footer_auxiliary_spans_show_cache_when_compact() {
|
||||
app.session.last_prompt_cache_miss_tokens = Some(12_000);
|
||||
app.session.session_cost = 12.34;
|
||||
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 14));
|
||||
assert!(compact.contains("cache"));
|
||||
let compact = spans_text(&footer_auxiliary_spans(&app, 48));
|
||||
assert!(compact.contains("Cache: 75.0% hit"));
|
||||
assert!(!compact.contains('$'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {
|
||||
let mut app = create_test_app();
|
||||
app.session.last_prompt_tokens = Some(48_000);
|
||||
app.session.last_completion_tokens = Some(2_000);
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
|
||||
|
||||
assert!(roomy.contains("Cache: unavailable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
|
||||
let mut app = create_test_app();
|
||||
@@ -1496,8 +1507,8 @@ fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
|
||||
app.session.last_prompt_cache_miss_tokens = Some(12_000);
|
||||
app.session.session_cost = 12.34;
|
||||
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
|
||||
assert!(roomy.contains("cache hit 75%"));
|
||||
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
|
||||
assert!(roomy.contains("Cache: 75.0% hit | hit 36000 | miss 12000"));
|
||||
assert!(roomy.contains("$12.34"));
|
||||
assert!(
|
||||
!roomy.contains("ctx"),
|
||||
|
||||
Reference in new issue
Block a user