mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-12 03:51:01 +00:00
618 lines
22 KiB
Rust
618 lines
22 KiB
Rust
use std::collections::{BTreeMap, HashSet};
|
|
|
|
use chrono::Utc;
|
|
use fabro_llm::token_count::{
|
|
estimate_message_tokens, estimate_request_control_tokens, estimate_text_tokens,
|
|
estimate_tool_definition_tokens, is_local_estimator_warning,
|
|
};
|
|
use fabro_llm::types::{Request, Role, TokenCounts, Warning as LlmWarning};
|
|
use fabro_types::{
|
|
StageContextWindowBreakdownItem, StageContextWindowCategory, StageContextWindowCountMethod,
|
|
StageContextWindowProjection, StageContextWindowStaleness, StageContextWindowWarning,
|
|
};
|
|
|
|
use crate::memory::MemoryDocument;
|
|
use crate::native_tool::ToolVocabulary;
|
|
use crate::skills::{Skill, format_skills_prompt_section};
|
|
use crate::tool_registry::{ToolDefinitionWithSource, ToolSource};
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub(crate) struct ContextWindowInput<'a> {
|
|
pub request: &'a Request,
|
|
pub tools: &'a [ToolDefinitionWithSource],
|
|
pub system_prompt: &'a str,
|
|
pub memory: &'a [MemoryDocument],
|
|
pub skills: &'a [Skill],
|
|
pub tool_vocabulary: ToolVocabulary,
|
|
pub activated_skill_context_observed: bool,
|
|
pub provider: &'a str,
|
|
pub model: &'a str,
|
|
pub context_window_tokens: usize,
|
|
}
|
|
|
|
#[must_use]
|
|
pub(crate) fn build_local_snapshot(input: ContextWindowInput<'_>) -> StageContextWindowProjection {
|
|
let mut builder = BreakdownBuilder::default();
|
|
let mut warnings = Vec::new();
|
|
|
|
add_message_breakdown(&mut builder, &mut warnings, &input);
|
|
add_tool_breakdown(&mut builder, input.tools);
|
|
add_request_control_breakdown(&mut builder, &mut warnings, input.request);
|
|
|
|
if input.activated_skill_context_observed {
|
|
warnings.push(StageContextWindowWarning {
|
|
code: "activated_skill_context_counted_as_conversation".to_string(),
|
|
message: "Activated skill instructions are counted as conversation in this version."
|
|
.to_string(),
|
|
});
|
|
}
|
|
|
|
builder.into_snapshot(SnapshotMeta {
|
|
provider: input.provider.to_string(),
|
|
model: input.model.to_string(),
|
|
context_window_tokens: u64::try_from(input.context_window_tokens).unwrap_or(u64::MAX),
|
|
count_method: StageContextWindowCountMethod::LocalEstimate,
|
|
staleness: StageContextWindowStaleness::Live,
|
|
warnings: dedupe_warnings_by_code(warnings),
|
|
})
|
|
}
|
|
|
|
/// Collapse a snapshot's warning list to one entry per `code`, preserving
|
|
/// insertion order. The per-message estimator already dedupes within a single
|
|
/// message — but `build_local_snapshot` walks every message in the request, so
|
|
/// the same opaque/media/etc. warning code accumulates one copy per turn that
|
|
/// triggered it. The user only needs to be told once.
|
|
#[must_use]
|
|
fn dedupe_warnings_by_code(
|
|
warnings: Vec<StageContextWindowWarning>,
|
|
) -> Vec<StageContextWindowWarning> {
|
|
let mut seen: HashSet<String> = HashSet::new();
|
|
warnings
|
|
.into_iter()
|
|
.filter(|w| seen.insert(w.code.clone()))
|
|
.collect()
|
|
}
|
|
|
|
#[must_use]
|
|
pub(crate) fn scaled_snapshot(
|
|
local: &StageContextWindowProjection,
|
|
input_tokens: u64,
|
|
count_method: StageContextWindowCountMethod,
|
|
warnings: Vec<StageContextWindowWarning>,
|
|
) -> StageContextWindowProjection {
|
|
let breakdown = scale_breakdown(&local.breakdown, input_tokens, local.context_window_tokens);
|
|
// When the displayed total is provider-authoritative, drop warnings that
|
|
// are only about local-estimator imprecision — they describe the per-
|
|
// category split, not the total the user sees, and tend to alarm users
|
|
// about a number that's actually correct.
|
|
let warnings = if total_is_provider_authoritative(count_method) {
|
|
warnings
|
|
.into_iter()
|
|
.filter(|w| !is_local_estimator_warning(&w.code))
|
|
.collect()
|
|
} else {
|
|
warnings
|
|
};
|
|
let warnings = dedupe_warnings_by_code(warnings);
|
|
StageContextWindowProjection {
|
|
provider: local.provider.clone(),
|
|
model: local.model.clone(),
|
|
context_window_tokens: local.context_window_tokens,
|
|
input_tokens,
|
|
usage_percent: usage_percent(input_tokens, local.context_window_tokens),
|
|
count_method,
|
|
staleness: StageContextWindowStaleness::Live,
|
|
generated_at: Utc::now(),
|
|
event_seq: None,
|
|
breakdown,
|
|
warnings,
|
|
}
|
|
}
|
|
|
|
const fn total_is_provider_authoritative(method: StageContextWindowCountMethod) -> bool {
|
|
matches!(
|
|
method,
|
|
StageContextWindowCountMethod::ProviderApiScaledBreakdown
|
|
| StageContextWindowCountMethod::ResponseUsageScaledBreakdown
|
|
)
|
|
}
|
|
|
|
/// Build a projection from a previously-computed local snapshot and the
|
|
/// token usage returned by the LLM response. If the response carried no
|
|
/// usable input tokens, fall back to the local estimate unchanged.
|
|
#[must_use]
|
|
pub(crate) fn context_window_from_response_usage(
|
|
local_snapshot: &StageContextWindowProjection,
|
|
usage: &TokenCounts,
|
|
) -> StageContextWindowProjection {
|
|
let input_tokens = usage
|
|
.input_tokens
|
|
.saturating_add(usage.cache_read_tokens)
|
|
.saturating_add(usage.cache_write_tokens);
|
|
if input_tokens <= 0 {
|
|
return local_snapshot.clone();
|
|
}
|
|
scaled_snapshot(
|
|
local_snapshot,
|
|
u64::try_from(input_tokens).unwrap_or(u64::MAX),
|
|
StageContextWindowCountMethod::ResponseUsageScaledBreakdown,
|
|
local_snapshot.warnings.clone(),
|
|
)
|
|
}
|
|
|
|
#[must_use]
|
|
fn warnings_from_llm(warnings: &[LlmWarning]) -> Vec<StageContextWindowWarning> {
|
|
warnings
|
|
.iter()
|
|
.map(|warning| StageContextWindowWarning {
|
|
code: warning
|
|
.code
|
|
.clone()
|
|
.unwrap_or_else(|| "token_count_warning".to_string()),
|
|
message: warning.message.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn add_message_breakdown(
|
|
builder: &mut BreakdownBuilder,
|
|
warnings: &mut Vec<StageContextWindowWarning>,
|
|
input: &ContextWindowInput<'_>,
|
|
) {
|
|
let memory_text = memory_prompt_suffix(input.memory);
|
|
let skills_text = skills_prompt_suffix(input.skills, input.tool_vocabulary);
|
|
let memory_tokens = estimate_text_tokens(&memory_text);
|
|
let skills_tokens = estimate_text_tokens(&skills_text);
|
|
let mut system_parts_seen = false;
|
|
|
|
for message in &input.request.messages {
|
|
let estimate = estimate_message_tokens(message);
|
|
warnings.extend(warnings_from_llm(&estimate.warnings));
|
|
if message.role == Role::System
|
|
&& !system_parts_seen
|
|
&& message.text() == input.system_prompt
|
|
{
|
|
system_parts_seen = true;
|
|
let attributed_suffix = memory_tokens.saturating_add(skills_tokens);
|
|
builder.add(
|
|
StageContextWindowCategory::SystemPrompt,
|
|
estimate.tokens.saturating_sub(attributed_suffix),
|
|
);
|
|
builder.add(StageContextWindowCategory::Memory, memory_tokens);
|
|
builder.add(StageContextWindowCategory::Skills, skills_tokens);
|
|
} else {
|
|
builder.add(StageContextWindowCategory::Conversation, estimate.tokens);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_tool_breakdown(builder: &mut BreakdownBuilder, tools: &[ToolDefinitionWithSource]) {
|
|
for tool in tools {
|
|
let tokens = estimate_tool_definition_tokens(&tool.definition);
|
|
match &tool.source {
|
|
ToolSource::Native => builder.add(StageContextWindowCategory::Tools, tokens),
|
|
ToolSource::Mcp { .. } => builder.add(StageContextWindowCategory::McpTools, tokens),
|
|
ToolSource::Skill => builder.add(StageContextWindowCategory::Skills, tokens),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn add_request_control_breakdown(
|
|
builder: &mut BreakdownBuilder,
|
|
warnings: &mut Vec<StageContextWindowWarning>,
|
|
request: &Request,
|
|
) {
|
|
let estimate = estimate_request_control_tokens(request);
|
|
warnings.extend(warnings_from_llm(&estimate.warnings));
|
|
builder.add(StageContextWindowCategory::Other, estimate.tokens);
|
|
}
|
|
|
|
fn memory_prompt_suffix(memory: &[MemoryDocument]) -> String {
|
|
if memory.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!(
|
|
"\n\n{}",
|
|
memory
|
|
.iter()
|
|
.map(|document| document.content.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n\n")
|
|
)
|
|
}
|
|
}
|
|
|
|
fn skills_prompt_suffix(skills: &[Skill], vocabulary: ToolVocabulary) -> String {
|
|
let section = format_skills_prompt_section(skills, vocabulary);
|
|
if section.is_empty() {
|
|
String::new()
|
|
} else {
|
|
format!("\n\n{section}")
|
|
}
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct BreakdownBuilder {
|
|
tokens: BTreeMap<StageContextWindowCategory, u64>,
|
|
}
|
|
|
|
impl BreakdownBuilder {
|
|
fn add(&mut self, category: StageContextWindowCategory, tokens: usize) {
|
|
if tokens == 0 {
|
|
return;
|
|
}
|
|
let tokens = u64::try_from(tokens).unwrap_or(u64::MAX);
|
|
self.tokens
|
|
.entry(category)
|
|
.and_modify(|existing| *existing = existing.saturating_add(tokens))
|
|
.or_insert(tokens);
|
|
}
|
|
|
|
fn into_snapshot(self, meta: SnapshotMeta) -> StageContextWindowProjection {
|
|
let input_tokens = self.tokens.values().copied().sum::<u64>();
|
|
let breakdown = self
|
|
.tokens
|
|
.into_iter()
|
|
.map(|(category, tokens)| StageContextWindowBreakdownItem {
|
|
category,
|
|
tokens,
|
|
usage_percent: usage_percent(tokens, meta.context_window_tokens),
|
|
})
|
|
.collect();
|
|
StageContextWindowProjection {
|
|
provider: meta.provider,
|
|
model: meta.model,
|
|
context_window_tokens: meta.context_window_tokens,
|
|
input_tokens,
|
|
usage_percent: usage_percent(input_tokens, meta.context_window_tokens),
|
|
count_method: meta.count_method,
|
|
staleness: meta.staleness,
|
|
generated_at: Utc::now(),
|
|
event_seq: None,
|
|
breakdown,
|
|
warnings: meta.warnings,
|
|
}
|
|
}
|
|
}
|
|
|
|
struct SnapshotMeta {
|
|
provider: String,
|
|
model: String,
|
|
context_window_tokens: u64,
|
|
count_method: StageContextWindowCountMethod,
|
|
staleness: StageContextWindowStaleness,
|
|
warnings: Vec<StageContextWindowWarning>,
|
|
}
|
|
|
|
/// Proportionally scale a local breakdown so it sums to `target_total`. Any
|
|
/// rounding leftover is absorbed by the last bucket; this is a best-effort
|
|
/// estimate, not exact apportionment.
|
|
fn scale_breakdown(
|
|
breakdown: &[StageContextWindowBreakdownItem],
|
|
target_total: u64,
|
|
context_window_tokens: u64,
|
|
) -> Vec<StageContextWindowBreakdownItem> {
|
|
let local_total = breakdown.iter().map(|item| item.tokens).sum::<u64>();
|
|
if breakdown.is_empty() || local_total == 0 {
|
|
return (target_total > 0)
|
|
.then(|| StageContextWindowBreakdownItem {
|
|
category: StageContextWindowCategory::Other,
|
|
tokens: target_total,
|
|
usage_percent: usage_percent(target_total, context_window_tokens),
|
|
})
|
|
.into_iter()
|
|
.collect();
|
|
}
|
|
|
|
let mut scaled: Vec<_> = breakdown
|
|
.iter()
|
|
.map(|item| {
|
|
let scaled = u128::from(item.tokens).saturating_mul(u128::from(target_total))
|
|
/ u128::from(local_total);
|
|
let tokens = u64::try_from(scaled).unwrap_or(u64::MAX);
|
|
StageContextWindowBreakdownItem {
|
|
category: item.category,
|
|
tokens,
|
|
usage_percent: usage_percent(tokens, context_window_tokens),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
// Push any rounding leftover into the last bucket so totals match exactly.
|
|
let allocated: u64 = scaled.iter().map(|item| item.tokens).sum();
|
|
if let Some(last) = scaled.last_mut() {
|
|
let leftover = target_total.saturating_sub(allocated);
|
|
if leftover > 0 {
|
|
last.tokens = last.tokens.saturating_add(leftover);
|
|
last.usage_percent = usage_percent(last.tokens, context_window_tokens);
|
|
}
|
|
}
|
|
scaled
|
|
}
|
|
|
|
fn usage_percent(tokens: u64, denominator: u64) -> f64 {
|
|
if denominator == 0 {
|
|
0.0
|
|
} else {
|
|
(tokens as f64) * 100.0 / (denominator as f64)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use fabro_llm::types::{Message as LlmMessage, Request, ToolChoice, ToolDefinition};
|
|
|
|
use super::*;
|
|
use crate::tool_registry::ToolDefinitionWithSource;
|
|
|
|
fn request(messages: Vec<LlmMessage>, tools: Vec<ToolDefinition>) -> Request {
|
|
Request {
|
|
model: "model-a".to_string(),
|
|
messages,
|
|
provider: Some("test".to_string()),
|
|
tools: (!tools.is_empty()).then_some(tools),
|
|
tool_choice: Some(ToolChoice::Auto),
|
|
response_format: None,
|
|
temperature: None,
|
|
top_p: None,
|
|
max_tokens: None,
|
|
stop_sequences: None,
|
|
reasoning_effort: None,
|
|
speed: None,
|
|
metadata: None,
|
|
provider_options: None,
|
|
}
|
|
}
|
|
|
|
fn tool(name: &str, source: ToolSource) -> ToolDefinitionWithSource {
|
|
ToolDefinitionWithSource {
|
|
definition: ToolDefinition::function(
|
|
name,
|
|
format!("{name} description"),
|
|
serde_json::json!({"type": "object"}),
|
|
),
|
|
source,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn local_breakdown_buckets_system_memory_skills_tools_and_conversation() {
|
|
let memory = vec![MemoryDocument {
|
|
path: "/repo/AGENTS.md".to_string(),
|
|
content: "memory instructions".to_string(),
|
|
byte_count: 19,
|
|
loaded_bytes: 19,
|
|
truncated: false,
|
|
}];
|
|
let skills = vec![Skill {
|
|
name: "commit".to_string(),
|
|
description: "Commit changes".to_string(),
|
|
template: "commit template".to_string(),
|
|
}];
|
|
let system_prompt = format!(
|
|
"core prompt{}{}",
|
|
memory_prompt_suffix(&memory),
|
|
skills_prompt_suffix(&skills, ToolVocabulary::Fabro)
|
|
);
|
|
let tools = vec![
|
|
tool("read_file", ToolSource::Native),
|
|
tool("mcp__server__search", ToolSource::Mcp {
|
|
server_name: "server".to_string(),
|
|
original_name: "search".to_string(),
|
|
}),
|
|
tool("use_skill", ToolSource::Skill),
|
|
];
|
|
let req = request(
|
|
vec![
|
|
LlmMessage::system(system_prompt.clone()),
|
|
LlmMessage::user("hello"),
|
|
],
|
|
tools.iter().map(|tool| tool.definition.clone()).collect(),
|
|
);
|
|
|
|
let snapshot = build_local_snapshot(ContextWindowInput {
|
|
request: &req,
|
|
tools: &tools,
|
|
system_prompt: &system_prompt,
|
|
memory: &memory,
|
|
skills: &skills,
|
|
tool_vocabulary: ToolVocabulary::Fabro,
|
|
activated_skill_context_observed: true,
|
|
provider: "test",
|
|
model: "model-a",
|
|
context_window_tokens: 100_000,
|
|
});
|
|
|
|
let categories = snapshot
|
|
.breakdown
|
|
.iter()
|
|
.map(|item| item.category)
|
|
.collect::<Vec<_>>();
|
|
assert!(categories.contains(&StageContextWindowCategory::SystemPrompt));
|
|
assert!(categories.contains(&StageContextWindowCategory::Memory));
|
|
assert!(categories.contains(&StageContextWindowCategory::Skills));
|
|
assert!(categories.contains(&StageContextWindowCategory::Tools));
|
|
assert!(categories.contains(&StageContextWindowCategory::McpTools));
|
|
assert!(categories.contains(&StageContextWindowCategory::Conversation));
|
|
assert_eq!(
|
|
snapshot
|
|
.breakdown
|
|
.iter()
|
|
.map(|item| item.tokens)
|
|
.sum::<u64>(),
|
|
snapshot.input_tokens
|
|
);
|
|
assert!(
|
|
snapshot.warnings.iter().any(|warning| {
|
|
warning.code == "activated_skill_context_counted_as_conversation"
|
|
})
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn skills_suffix_uses_the_profile_tool_vocabulary() {
|
|
let skills = vec![Skill {
|
|
name: "commit".to_string(),
|
|
description: "Commit changes".to_string(),
|
|
template: "commit template".to_string(),
|
|
}];
|
|
|
|
assert!(skills_prompt_suffix(&skills, ToolVocabulary::Fabro).contains("`use_skill`"));
|
|
assert!(skills_prompt_suffix(&skills, ToolVocabulary::KimiCode).contains("`Skill`"));
|
|
}
|
|
|
|
#[test]
|
|
fn scaled_breakdown_totals_provider_count() {
|
|
let local = StageContextWindowProjection {
|
|
provider: "test".to_string(),
|
|
model: "model-a".to_string(),
|
|
context_window_tokens: 1000,
|
|
input_tokens: 30,
|
|
usage_percent: 3.0,
|
|
count_method: StageContextWindowCountMethod::LocalEstimate,
|
|
staleness: StageContextWindowStaleness::Live,
|
|
generated_at: Utc::now(),
|
|
event_seq: None,
|
|
breakdown: vec![
|
|
StageContextWindowBreakdownItem {
|
|
category: StageContextWindowCategory::SystemPrompt,
|
|
tokens: 10,
|
|
usage_percent: 0.0,
|
|
},
|
|
StageContextWindowBreakdownItem {
|
|
category: StageContextWindowCategory::Conversation,
|
|
tokens: 20,
|
|
usage_percent: 0.0,
|
|
},
|
|
],
|
|
warnings: Vec::new(),
|
|
};
|
|
|
|
let scaled = scaled_snapshot(
|
|
&local,
|
|
101,
|
|
StageContextWindowCountMethod::ProviderApiScaledBreakdown,
|
|
Vec::new(),
|
|
);
|
|
|
|
assert_eq!(scaled.input_tokens, 101);
|
|
assert_eq!(
|
|
scaled.breakdown.iter().map(|item| item.tokens).sum::<u64>(),
|
|
101
|
|
);
|
|
}
|
|
|
|
/// Build a minimal snapshot with one estimator-noise warning and one
|
|
/// semantic warning, used by the warning-suppression assertions below.
|
|
fn snapshot_for_warning_test() -> StageContextWindowProjection {
|
|
StageContextWindowProjection {
|
|
provider: "test".to_string(),
|
|
model: "model-a".to_string(),
|
|
context_window_tokens: 1000,
|
|
input_tokens: 50,
|
|
usage_percent: 5.0,
|
|
count_method: StageContextWindowCountMethod::LocalEstimate,
|
|
staleness: StageContextWindowStaleness::Live,
|
|
generated_at: Utc::now(),
|
|
event_seq: None,
|
|
breakdown: vec![StageContextWindowBreakdownItem {
|
|
category: StageContextWindowCategory::Conversation,
|
|
tokens: 50,
|
|
usage_percent: 5.0,
|
|
}],
|
|
warnings: Vec::new(),
|
|
}
|
|
}
|
|
|
|
fn warnings_in() -> Vec<StageContextWindowWarning> {
|
|
use fabro_llm::token_count::{MEDIA_ESTIMATE_WARNING, OPAQUE_CONTEXT_ESTIMATE_WARNING};
|
|
vec![
|
|
StageContextWindowWarning {
|
|
code: OPAQUE_CONTEXT_ESTIMATE_WARNING.to_string(),
|
|
message: "noise".to_string(),
|
|
},
|
|
StageContextWindowWarning {
|
|
code: MEDIA_ESTIMATE_WARNING.to_string(),
|
|
message: "noise".to_string(),
|
|
},
|
|
StageContextWindowWarning {
|
|
code: "activated_skill_context_counted_as_conversation".to_string(),
|
|
message: "kept".to_string(),
|
|
},
|
|
]
|
|
}
|
|
|
|
#[test]
|
|
fn scaled_snapshot_drops_estimator_noise_when_total_is_provider_authoritative() {
|
|
let local = snapshot_for_warning_test();
|
|
let scaled = scaled_snapshot(
|
|
&local,
|
|
100,
|
|
StageContextWindowCountMethod::ProviderApiScaledBreakdown,
|
|
warnings_in(),
|
|
);
|
|
let codes: Vec<_> = scaled.warnings.iter().map(|w| w.code.as_str()).collect();
|
|
assert_eq!(codes, vec![
|
|
"activated_skill_context_counted_as_conversation"
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn scaled_snapshot_drops_estimator_noise_under_response_usage_scaling() {
|
|
let local = snapshot_for_warning_test();
|
|
let scaled = scaled_snapshot(
|
|
&local,
|
|
100,
|
|
StageContextWindowCountMethod::ResponseUsageScaledBreakdown,
|
|
warnings_in(),
|
|
);
|
|
let codes: Vec<_> = scaled.warnings.iter().map(|w| w.code.as_str()).collect();
|
|
assert_eq!(codes, vec![
|
|
"activated_skill_context_counted_as_conversation"
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn scaled_snapshot_keeps_estimator_warnings_for_local_estimate() {
|
|
let local = snapshot_for_warning_test();
|
|
let scaled = scaled_snapshot(
|
|
&local,
|
|
100,
|
|
StageContextWindowCountMethod::LocalEstimate,
|
|
warnings_in(),
|
|
);
|
|
let codes: Vec<_> = scaled.warnings.iter().map(|w| w.code.as_str()).collect();
|
|
// When the total itself is locally estimated, the estimator-noise
|
|
// warnings remain meaningful and must surface.
|
|
assert_eq!(codes, vec![
|
|
"opaque_context_estimate",
|
|
"media_token_estimate",
|
|
"activated_skill_context_counted_as_conversation",
|
|
]);
|
|
}
|
|
|
|
#[test]
|
|
fn scaled_snapshot_dedupes_repeated_warning_codes() {
|
|
use fabro_llm::token_count::OPAQUE_CONTEXT_ESTIMATE_WARNING;
|
|
let local = snapshot_for_warning_test();
|
|
// Simulate the real bug: build_local_snapshot walks N messages and
|
|
// adds the same `opaque_context_estimate` warning once per turn that
|
|
// had an opaque block, so a long conversation accumulates many copies.
|
|
let repeated: Vec<_> = (0..5)
|
|
.map(|i| StageContextWindowWarning {
|
|
code: OPAQUE_CONTEXT_ESTIMATE_WARNING.to_string(),
|
|
message: format!("turn {i}"),
|
|
})
|
|
.collect();
|
|
|
|
let scaled = scaled_snapshot(
|
|
&local,
|
|
100,
|
|
StageContextWindowCountMethod::LocalEstimate,
|
|
repeated,
|
|
);
|
|
|
|
let codes: Vec<_> = scaled.warnings.iter().map(|w| w.code.as_str()).collect();
|
|
assert_eq!(codes, vec!["opaque_context_estimate"]);
|
|
}
|
|
}
|