Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use crate::agentic::WorkspaceBinding;
use crate::infrastructure::ai::get_global_ai_client_factory;
use crate::service::config::get_global_config_service;
use crate::service::config::types::{
model_runtime_binding_fingerprint, ModelCapability, ModelCategory,
automatic_max_output_tokens, model_runtime_binding_fingerprint, ModelCapability, ModelCategory,
};
use crate::util::errors::{BitFunError, BitFunResult};
use crate::util::token_counter::TokenCounter;
Expand Down Expand Up @@ -342,7 +342,6 @@ pub struct ExecutionEngine {
}

impl ExecutionEngine {
const AUTO_COMPRESSION_DEFAULT_OUTPUT_RESERVE_TOKENS: usize = 16_000;
const AUTO_COMPRESSION_SAFETY_RESERVE_TOKENS: usize = 10_000;
const FINALIZE_AFTER_REPEATED_TOOL_FAILURES_REMINDER: &'static str = "This turn must end now because repeated tool failures have prevented further progress. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize what was completed, what failed, the evidence available from the tool results, and the single best next step for the user.";
const FINALIZE_AFTER_MAX_ROUNDS_REMINDER: &'static str = "This turn must end now because it has reached the round limit. Ignore any unfinished work. Your task now is to give the user a final answer. Do not call any more tools; any tool call will fail. Respond in plain text only. Summarize the most useful completed work and evidence collected so far, and clearly distinguish resolved items from anything still unresolved.";
Expand Down Expand Up @@ -516,7 +515,7 @@ impl ExecutionEngine {
) -> CompressionTriggerBudget {
let output_reserve_tokens = configured_max_tokens
.map(|value| value as usize)
.unwrap_or(Self::AUTO_COMPRESSION_DEFAULT_OUTPUT_RESERVE_TOKENS);
.unwrap_or_else(|| automatic_max_output_tokens(context_window as u32) as usize);
let safety_reserve_tokens = Self::AUTO_COMPRESSION_SAFETY_RESERVE_TOKENS;
let input_limit =
context_window.saturating_sub(output_reserve_tokens + safety_reserve_tokens);
Expand Down Expand Up @@ -4074,12 +4073,12 @@ mod tests {
}

#[test]
fn compression_trigger_budget_uses_16k_output_reserve_when_max_tokens_is_unset() {
fn compression_trigger_budget_uses_the_automatic_output_tier_when_max_tokens_is_unset() {
let budget = ExecutionEngine::compression_trigger_budget(128_000, None);

assert_eq!(budget.output_reserve_tokens, 16_000);
assert_eq!(budget.output_reserve_tokens, 32_000);
assert_eq!(budget.safety_reserve_tokens, 10_000);
assert_eq!(budget.input_limit, 102_000);
assert_eq!(budget.input_limit, 86_000);
}

#[test]
Expand Down
32 changes: 29 additions & 3 deletions src/crates/assembly/core/src/service/config/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ impl ConfigProvider for AIConfigProvider {
warnings.push(format!("Model '{}' has empty API key", model.name));
}
if let Some(context_window) = model.context_window {
if context_window == 0 {
if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS {
return Err(BitFunError::validation(format!(
"Model '{}' context_window must be greater than 0",
model.name
"Model '{}' context_window must be at least {}",
model.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS
)));
}
}
Expand Down Expand Up @@ -160,6 +160,32 @@ impl ConfigProvider for AIConfigProvider {
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn rejects_a_model_context_window_smaller_than_32k() {
let mut config = AIConfig::default();
config.models.push(AIModelConfig {
name: "Test model".to_string(),
provider: "openai".to_string(),
context_window: Some(MIN_MODEL_CONTEXT_WINDOW_TOKENS - 1),
..AIModelConfig::default()
});
let value = serde_json::to_value(config).expect("AI config should serialize");

let error = AIConfigProvider
.validate_config(&value)
.await
.expect_err("small context windows must be rejected");

assert!(error
.to_string()
.contains("context_window must be at least 32000"));
}
}

/// Theme system configuration provider (new, supports theme management).
pub struct ThemesConfigProvider;

Expand Down
27 changes: 26 additions & 1 deletion src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,30 @@ pub enum AgentSubagentOverrideState {
pub type ParentSubagentOverrideConfig = HashMap<String, AgentSubagentOverrideState>;
pub type AgentSubagentOverrideConfig = HashMap<String, ParentSubagentOverrideConfig>;

pub const DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 128_128;
pub const MIN_MODEL_CONTEXT_WINDOW_TOKENS: u32 = 32_000;
pub const MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT: u32 = 40;
const AUTOMATIC_MAX_OUTPUT_TOKEN_TIERS: [u32; 5] = [8_000, 16_000, 24_000, 32_000, 64_000];

/// Chooses the largest supported output tier that does not exceed one quarter
/// of the model context window.
pub fn automatic_max_output_tokens(context_window: u32) -> u32 {
let quarter_context = context_window / 4;
AUTOMATIC_MAX_OUTPUT_TOKEN_TIERS
.iter()
.rev()
.copied()
.find(|tier| *tier <= quarter_context)
.unwrap_or(quarter_context)
}

/// A configured output cap may use up to 40% of the model context window.
pub fn is_valid_configured_max_output_tokens(context_window: u32, max_tokens: u32) -> bool {
max_tokens > 0
&& u64::from(max_tokens) * 100
<= u64::from(context_window) * u64::from(MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT)
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, from = "AIModelConfigCompat")]
pub struct AIModelConfig {
Expand All @@ -1239,7 +1263,8 @@ pub struct AIModelConfig {
pub api_key: String,
/// Context window size (total token limit for input + output).
pub context_window: Option<u32>,
/// Max output tokens (request parameter limiting model output length).
/// Optional advanced override for the request output limit. When absent,
/// BitFun derives a tiered limit from the context window at runtime.
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
Expand Down
97 changes: 95 additions & 2 deletions src/crates/assembly/core/src/util/types/config.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
use crate::service::config::types::AIModelConfig;
use crate::service::config::types::{
automatic_max_output_tokens, is_valid_configured_max_output_tokens,
DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS, MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT,
MIN_MODEL_CONTEXT_WINDOW_TOKENS,
};
pub use bitfun_core_types::AIConfig;
use log::warn;

Expand Down Expand Up @@ -94,15 +99,50 @@ impl TryFrom<AIModelConfig> for AIConfig {
resolve_request_url(&other.base_url, &other.provider, &other.model_name)
});

let context_window = other
.context_window
.unwrap_or(DEFAULT_MODEL_CONTEXT_WINDOW_TOKENS);
if context_window < MIN_MODEL_CONTEXT_WINDOW_TOKENS {
return Err(format!(
"Model '{}' context_window must be at least {}",
other.name, MIN_MODEL_CONTEXT_WINDOW_TOKENS
));
}

let max_tokens = match other.max_tokens {
Some(configured_max_tokens)
if is_valid_configured_max_output_tokens(context_window, configured_max_tokens) =>
{
configured_max_tokens
}
Some(configured_max_tokens) => {
let automatic_max_tokens = automatic_max_output_tokens(context_window);
let maximum_allowed_tokens = u64::from(context_window)
* u64::from(MAX_CONFIGURED_OUTPUT_TOKENS_RATIO_PERCENT)
/ 100;
warn!(
"Invalid model max_tokens; falling back to automatic output limit: model_id={}, model_name={}, context_window={}, configured_max_tokens={}, maximum_allowed_tokens={}, automatic_max_tokens={}",
other.id,
other.name,
context_window,
configured_max_tokens,
maximum_allowed_tokens,
automatic_max_tokens
);
automatic_max_tokens
}
None => automatic_max_output_tokens(context_window),
};

Ok(AIConfig {
name: other.name.clone(),
base_url: other.base_url.clone(),
request_url,
api_key: other.api_key.clone(),
model: other.model_name.clone(),
format: other.provider.clone(),
context_window: other.context_window.unwrap_or(128128),
max_tokens: other.max_tokens,
context_window,
max_tokens: Some(max_tokens),
temperature: other.temperature,
top_p: other.top_p,
reasoning_mode,
Expand Down Expand Up @@ -237,4 +277,57 @@ mod tests {
let config = AIConfig::try_from(model).expect("conversion should succeed");
assert_eq!(config.reasoning_mode, ReasoningMode::Enabled);
}

#[test]
fn derives_the_largest_output_tier_within_one_quarter_of_context() {
for (context_window, expected_max_tokens) in [
(32_000, 8_000),
(48_000, 8_000),
(64_000, 16_000),
(128_000, 32_000),
(128_128, 32_000),
(256_000, 64_000),
(1_000_000, 64_000),
] {
let mut model = base_model_config();
model.context_window = Some(context_window);
model.max_tokens = None;

let config = AIConfig::try_from(model).expect("conversion should succeed");

assert_eq!(config.max_tokens, Some(expected_max_tokens));
}
}

#[test]
fn preserves_a_configured_output_limit_within_forty_percent_of_context() {
let mut model = base_model_config();
model.context_window = Some(1_000_000);
model.max_tokens = Some(384_000);

let config = AIConfig::try_from(model).expect("conversion should succeed");

assert_eq!(config.max_tokens, Some(384_000));
}

#[test]
fn falls_back_to_the_automatic_output_limit_when_configured_limit_is_too_large() {
let mut model = base_model_config();
model.context_window = Some(128_000);
model.max_tokens = Some(64_000);

let config = AIConfig::try_from(model).expect("conversion should succeed");

assert_eq!(config.max_tokens, Some(32_000));
}

#[test]
fn rejects_a_context_window_smaller_than_the_supported_minimum() {
let mut model = base_model_config();
model.context_window = Some(16_000);

let error = AIConfig::try_from(model).expect_err("conversion should reject small context");

assert!(error.contains("at least 32000"));
}
}
Loading
Loading