| use std::borrow::Cow; |
|
|
| use derive_more::derive::Display; |
| use derive_setters::Setters; |
| use merge::Merge; |
| use schemars::JsonSchema; |
| use serde::{Deserialize, Serialize}; |
| use strum_macros::{Display as StrumDisplay, EnumString}; |
|
|
| use crate::{ |
| Compact, Error, EventContext, MaxTokens, Model, ModelId, ProviderId, Result, SystemContext, |
| Temperature, Template, ToolDefinition, ToolName, TopK, TopP, |
| }; |
|
|
| |
| #[derive(Debug, Display, Eq, PartialEq, Hash, Clone, Serialize, Deserialize, JsonSchema)] |
| #[serde(transparent)] |
| pub struct AgentId(Cow<'static, str>); |
|
|
| impl From<&str> for AgentId { |
| fn from(value: &str) -> Self { |
| AgentId(Cow::Owned(value.to_string())) |
| } |
| } |
|
|
| impl AgentId { |
| |
| pub fn new(id: impl ToString) -> Self { |
| Self(Cow::Owned(id.to_string())) |
| } |
|
|
| |
| pub fn as_str(&self) -> &str { |
| self.0.as_ref() |
| } |
|
|
| pub const FORGE: AgentId = AgentId(Cow::Borrowed("forge")); |
| pub const MUSE: AgentId = AgentId(Cow::Borrowed("muse")); |
| pub const SAGE: AgentId = AgentId(Cow::Borrowed("sage")); |
| } |
|
|
| impl Default for AgentId { |
| fn default() -> Self { |
| AgentId::FORGE |
| } |
| } |
|
|
| #[derive(Default, Debug, Clone, Serialize, Deserialize, Merge, Setters, JsonSchema, PartialEq)] |
| #[setters(strip_option)] |
| #[merge(strategy = merge::option::overwrite_none)] |
| pub struct ReasoningConfig { |
| |
| |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub effort: Option<Effort>, |
|
|
| |
| |
| |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub max_tokens: Option<usize>, |
|
|
| |
| |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub exclude: Option<bool>, |
|
|
| |
| |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub enabled: Option<bool>, |
| } |
|
|
| #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, StrumDisplay, EnumString)] |
| #[serde(rename_all = "lowercase")] |
| #[strum(serialize_all = "lowercase", ascii_case_insensitive)] |
| pub enum Effort { |
| |
| None, |
| |
| Minimal, |
| |
| Low, |
| |
| Medium, |
| |
| High, |
| |
| XHigh, |
| |
| Max, |
| } |
|
|
| |
| |
| |
| |
| |
| pub fn estimate_token_count(count: usize) -> usize { |
| |
| |
| count / 4 |
| } |
|
|
| |
| #[derive(Debug, Clone, PartialEq, Setters, Serialize, Deserialize, JsonSchema)] |
| #[setters(strip_option, into)] |
| pub struct Agent { |
| |
| pub id: AgentId, |
|
|
| |
| pub title: Option<String>, |
|
|
| |
| pub description: Option<String>, |
|
|
| |
| pub tool_supported: Option<bool>, |
|
|
| |
| pub path: Option<String>, |
|
|
| |
| pub provider: ProviderId, |
|
|
| |
| pub model: ModelId, |
|
|
| |
| pub system_prompt: Option<Template<SystemContext>>, |
|
|
| |
| pub user_prompt: Option<Template<EventContext>>, |
|
|
| |
| #[serde(skip_serializing_if = "Option::is_none")] |
| pub tools: Option<Vec<ToolName>>, |
|
|
| |
| pub max_turns: Option<u64>, |
|
|
| |
| pub compact: Compact, |
|
|
| |
| pub custom_rules: Option<String>, |
|
|
| |
| pub temperature: Option<Temperature>, |
|
|
| |
| pub top_p: Option<TopP>, |
|
|
| |
| pub top_k: Option<TopK>, |
|
|
| |
| pub max_tokens: Option<MaxTokens>, |
|
|
| |
| pub reasoning: Option<ReasoningConfig>, |
|
|
| |
| pub max_tool_failure_per_turn: Option<usize>, |
|
|
| |
| pub max_requests_per_turn: Option<usize>, |
| } |
|
|
| |
| |
| #[derive(Debug, Default, Clone, PartialEq, Setters, Serialize, Deserialize, JsonSchema)] |
| #[setters(strip_option, into)] |
| pub struct AgentInfo { |
| |
| pub id: AgentId, |
|
|
| |
| pub title: Option<String>, |
|
|
| |
| pub description: Option<String>, |
| } |
|
|
| impl Agent { |
| |
| pub fn new(id: impl Into<AgentId>, provider: ProviderId, model: ModelId) -> Self { |
| Self { |
| id: id.into(), |
| title: Default::default(), |
| description: Default::default(), |
| provider, |
| model, |
| tool_supported: Default::default(), |
| system_prompt: Default::default(), |
| user_prompt: Default::default(), |
| tools: Default::default(), |
| max_turns: Default::default(), |
| compact: Compact::default(), |
| custom_rules: Default::default(), |
| temperature: Default::default(), |
| top_p: Default::default(), |
| top_k: Default::default(), |
| max_tokens: Default::default(), |
| reasoning: Default::default(), |
| max_tool_failure_per_turn: Default::default(), |
| max_requests_per_turn: Default::default(), |
| path: Default::default(), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| pub fn tool_definition(&self) -> Result<ToolDefinition> { |
| if self.description.is_none() || self.description.as_ref().is_none_or(|d| d.is_empty()) { |
| return Err(Error::MissingAgentDescription(self.id.clone())); |
| } |
| Ok(ToolDefinition::new(self.id.as_str().to_string()) |
| .description(self.description.clone().unwrap())) |
| } |
|
|
| |
| pub fn set_compact_model_if_none(mut self) -> Self { |
| if self.compact.model.is_none() { |
| self.compact.model = Some(self.model.clone()); |
| } |
| self |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| pub fn compaction_threshold(mut self, selected_model: Option<&Model>) -> Self { |
| const DEFAULT_CONTEXT_WINDOW: usize = 128_000; |
| const DEFAULT_TOKEN_THRESHOLD: usize = 100_000; |
| const DEFAULT_CONTEXT_WINDOW_PERCENTAGE: f64 = 0.7; |
|
|
| let context_window = selected_model |
| .and_then(|model| model.context_length) |
| .and_then(|context_window| usize::try_from(context_window).ok()) |
| .unwrap_or(DEFAULT_CONTEXT_WINDOW); |
|
|
| let configured_threshold = self |
| .compact |
| .token_threshold |
| .unwrap_or(DEFAULT_TOKEN_THRESHOLD); |
| let context_window_percentage = self |
| .compact |
| .token_threshold_percentage |
| .unwrap_or(DEFAULT_CONTEXT_WINDOW_PERCENTAGE); |
| let context_window_threshold = |
| ((context_window as f64) * context_window_percentage).floor() as usize; |
|
|
| self.compact.token_threshold = Some(configured_threshold.min(context_window_threshold)); |
|
|
| self |
| } |
|
|
| |
| pub fn tool_order(&self) -> crate::ToolOrder { |
| self.tools |
| .as_ref() |
| .map(|tools| crate::ToolOrder::from_tool_list(tools)) |
| .unwrap_or_default() |
| } |
| } |
|
|
| impl From<Agent> for ToolDefinition { |
| fn from(value: Agent) -> Self { |
| let description = value.description.unwrap_or_default(); |
| let name = ToolName::new(value.id); |
| ToolDefinition { |
| name, |
| description, |
| input_schema: crate::tool_schema_generator() |
| .into_root_schema_for::<crate::AgentInput>(), |
| } |
| } |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
| use crate::{InputModality, Model}; |
|
|
| fn model_fixture(id: &str, context_length: Option<u64>) -> Model { |
| Model { |
| id: ModelId::new(id), |
| name: Some(id.to_string()), |
| description: None, |
| context_length, |
| tools_supported: Some(true), |
| supports_parallel_tool_calls: Some(true), |
| supports_reasoning: Some(true), |
| input_modalities: vec![InputModality::Text], |
| } |
| } |
|
|
| #[test] |
| fn test_cap_compact_token_threshold_by_context_window_caps_when_threshold_exceeds_context_window() |
| { |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("selected-model"), |
| ) |
| .compact(Compact::new().token_threshold(100_000_usize)); |
|
|
| let selected_model = model_fixture("selected-model", Some(80_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
| let expected = Some(56_000); |
|
|
| assert_eq!(actual.compact.token_threshold, expected); |
| } |
|
|
| #[test] |
| fn test_cap_compact_token_threshold_caps_to_safe_margin_when_within_context_window() { |
| |
| |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("selected-model"), |
| ) |
| .compact(Compact::new().token_threshold(60_000_usize)); |
|
|
| let selected_model = model_fixture("selected-model", Some(80_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
| |
| let expected = Some(56_000); |
|
|
| assert_eq!(actual.compact.token_threshold, expected); |
| } |
|
|
| #[test] |
| fn test_compaction_threshold_uses_configured_context_window_percentage_cap() { |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("selected-model"), |
| ) |
| .compact( |
| Compact::new() |
| .token_threshold(100_000_usize) |
| .token_threshold_percentage(0.5_f64), |
| ); |
|
|
| let selected_model = model_fixture("selected-model", Some(80_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
| let expected = Some(40_000); |
|
|
| assert_eq!(actual.compact.token_threshold, expected); |
| } |
|
|
| #[test] |
| fn test_compaction_threshold_uses_hardcoded_cap_when_context_window_cap_is_higher() { |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("selected-model"), |
| ); |
|
|
| let selected_model = model_fixture("selected-model", Some(200_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
| let expected = Some(100_000); |
|
|
| assert_eq!(actual.compact.token_threshold, expected); |
| } |
|
|
| #[test] |
| fn test_cap_compact_token_threshold_uses_default_when_selected_model_is_missing() { |
| |
| |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("selected-model"), |
| ) |
| .compact(Compact::new().token_threshold(100_000_usize)); |
|
|
| let actual = fixture.compaction_threshold(None); |
| |
| let expected = Some(89_600); |
|
|
| assert_eq!(actual.compact.token_threshold, expected); |
| } |
|
|
| |
| |
| |
| |
| #[test] |
| fn test_compaction_threshold_should_set_default_when_token_threshold_is_none() { |
| |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("gpt-5.3-codex-spark"), |
| ); |
| |
| assert_eq!(fixture.compact.token_threshold, None); |
|
|
| let selected_model = model_fixture("gpt-5.3-codex-spark", Some(128_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
|
|
| |
| |
| let expected_threshold = Some(89_600); |
| assert_eq!( |
| actual.compact.token_threshold, expected_threshold, |
| "BUG: compaction_threshold should set default to 70% of model context window when token_threshold is None, \ |
| but it returns early leaving it as None. This causes context_length_exceeded errors with codex-spark." |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| #[test] |
| fn test_compaction_threshold_insufficient_headroom_for_codex_spark() { |
| |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("gpt-5.3-codex-spark"), |
| ) |
| .compact(Compact::new().token_threshold(100_000_usize)); |
|
|
| let selected_model = model_fixture("gpt-5.3-codex-spark", Some(128_000)); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| |
| let expected_safe_threshold = Some(89_600); |
| assert_eq!( |
| actual.compact.token_threshold, expected_safe_threshold, |
| "BUG: With codex-spark (128K context), token_threshold of 100K leaves insufficient headroom. \ |
| Context can grow to 105K without compaction, then adding tool outputs pushes it over 128K limit. \ |
| Threshold should be capped to 70% of context window (89600) for safety." |
| ); |
| } |
|
|
| |
| |
| |
| #[test] |
| fn test_compaction_threshold_no_model_context_length_should_still_set_default() { |
| |
| let fixture = Agent::new( |
| AgentId::new("test"), |
| ProviderId::OPENAI, |
| ModelId::new("unknown-model"), |
| ); |
|
|
| |
| let selected_model = model_fixture("unknown-model", None); |
|
|
| let actual = fixture.compaction_threshold(Some(&selected_model)); |
|
|
| |
| |
| |
| assert!( |
| actual.compact.token_threshold.is_some(), |
| "BUG: compaction_threshold should set a default threshold even when model context_length is unknown. \ |
| Currently returns early with None, causing unbounded context growth." |
| ); |
| } |
| } |
|
|