use std::{
collections::{BTreeMap, BTreeSet},
path::PathBuf,
time::Duration,
};
#[cfg(test)]
use std::sync::atomic::{AtomicU64, Ordering};
use serde::{Deserialize, Serialize};
use crate::compaction::CompactionMode;
#[cfg(test)]
use crate::provider::ToolSearchMode;
use crate::provider::{ProviderRequestOptions, ToolChoice};
#[cfg(test)]
static NEXT_TEST_TRANSCRIPT_DIR_ID: AtomicU64 = AtomicU64::new(1);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaskConfig {
pub tasks_dir: PathBuf,
pub reminder_threshold: usize,
}
impl Default for TaskConfig {
fn default() -> Self {
Self {
tasks_dir: default_tasks_dir(),
reminder_threshold: 3,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamAutonomyConfig {
pub enabled: bool,
pub poll_interval: Duration,
pub idle_timeout: Duration,
}
impl Default for TeamAutonomyConfig {
fn default() -> Self {
Self {
enabled: false,
poll_interval: Duration::from_secs(5),
idle_timeout: Duration::from_secs(60),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamConfig {
pub team_dir: PathBuf,
pub autonomy: TeamAutonomyConfig,
}
impl Default for TeamConfig {
fn default() -> Self {
Self {
team_dir: default_team_dir(),
autonomy: TeamAutonomyConfig::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectedToolResultBudget {
pub max_bytes: usize,
pub prioritize_recent_results: usize,
pub max_preview_bytes: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum AutoCompactTrigger {
#[default]
Thresholds,
Off,
WindowShareOnly,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CompactionConfig {
pub keep_recent_tool_results: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub projected_tool_result_budget: Option<ProjectedToolResultBudget>,
#[serde(default)]
pub auto_compact_trigger: AutoCompactTrigger,
pub auto_compact_threshold_tokens: Option<usize>,
#[serde(default = "default_auto_compact_threshold_percent")]
pub auto_compact_threshold_percent: Option<u8>,
pub transcript_dir: PathBuf,
pub summary_max_input_chars: usize,
pub summary_max_output_tokens: u32,
#[serde(default)]
pub mode: CompactionMode,
pub preserve_recent_user_tokens: usize,
pub preserve_recent_delegation_results: usize,
pub max_persisted_transcripts: Option<usize>,
}
impl Default for CompactionConfig {
fn default() -> Self {
Self {
keep_recent_tool_results: usize::MAX,
projected_tool_result_budget: None,
auto_compact_trigger: AutoCompactTrigger::default(),
auto_compact_threshold_tokens: Some(50_000),
auto_compact_threshold_percent: default_auto_compact_threshold_percent(),
transcript_dir: default_transcript_dir(),
summary_max_input_chars: 80_000,
summary_max_output_tokens: 2_000,
mode: CompactionMode::LocalOnly,
preserve_recent_user_tokens: 20_000,
preserve_recent_delegation_results: 8,
max_persisted_transcripts: Some(10),
}
}
}
fn default_auto_compact_threshold_percent() -> Option<u8> {
Some(75)
}
impl CompactionConfig {
pub fn auto_compact_threshold(&self, context_window: Option<usize>) -> Option<usize> {
match self.auto_compact_trigger {
AutoCompactTrigger::Off => None,
AutoCompactTrigger::WindowShareOnly => Some(window_share(
context_window?,
self.auto_compact_threshold_percent?,
)),
AutoCompactTrigger::Thresholds => {
let fallback = self.auto_compact_threshold_tokens?;
match (context_window, self.auto_compact_threshold_percent) {
(Some(window), Some(percent)) => Some(window_share(window, percent)),
_ => Some(fallback),
}
}
}
}
pub fn auto_compact_enabled(&self) -> bool {
match self.auto_compact_trigger {
AutoCompactTrigger::Off => false,
AutoCompactTrigger::WindowShareOnly => self.auto_compact_threshold_percent.is_some(),
AutoCompactTrigger::Thresholds => self.auto_compact_threshold_tokens.is_some(),
}
}
}
fn window_share(window: usize, percent: u8) -> usize {
window.saturating_mul(percent.min(100) as usize) / 100
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolResultPagingConfig {
pub threshold_bytes: usize,
pub page_bytes: usize,
}
impl Default for ToolResultPagingConfig {
fn default() -> Self {
Self {
threshold_bytes: 64 * 1024,
page_bytes: 32 * 1024,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkspaceConfig {
pub base_dir: PathBuf,
pub auto_route_shell: bool,
}
impl Default for WorkspaceConfig {
fn default() -> Self {
let base_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
Self {
base_dir,
auto_route_shell: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MemoryConfig {
pub auto_recall_enabled: bool,
pub auto_recall_limit: usize,
pub auto_recall_char_budget: usize,
pub tool_search_limit: usize,
pub write_tools_enabled: bool,
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
auto_recall_enabled: true,
auto_recall_limit: 3,
auto_recall_char_budget: 2_000,
tool_search_limit: 10,
write_tools_enabled: true,
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolProfile {
#[serde(default)]
pub allowed_tools: Option<BTreeSet<String>>,
#[serde(default)]
pub hidden_tools: BTreeSet<String>,
}
impl ToolProfile {
pub fn all() -> Self {
Self::default()
}
pub fn only<I, S>(tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_tools: Some(tools.into_iter().map(Into::into).collect()),
hidden_tools: BTreeSet::new(),
}
}
pub fn hide<I, S>(tools: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
allowed_tools: None,
hidden_tools: tools.into_iter().map(Into::into).collect(),
}
}
pub fn allows(&self, tool_name: &str) -> bool {
if let Some(allowed_tools) = &self.allowed_tools
&& !allowed_tools.contains(tool_name)
{
return false;
}
!self.hidden_tools.contains(tool_name)
}
}
#[cfg(not(test))]
fn default_team_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().team_dir
}
#[cfg(test)]
fn default_team_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-team")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[cfg(not(test))]
fn default_transcript_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().transcripts_dir
}
#[cfg(not(test))]
fn default_tasks_dir() -> PathBuf {
crate::default_paths::workspace_default_paths().tasks_dir
}
#[cfg(test)]
fn default_tasks_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-tasks")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[cfg(test)]
fn default_transcript_dir() -> PathBuf {
let suffix = NEXT_TEST_TRANSCRIPT_DIR_ID.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir()
.join("mentra-test-transcripts")
.join(format!("process-{}-{suffix}", std::process::id()))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub system: Option<String>,
pub tool_choice: Option<ToolChoice>,
#[serde(default)]
pub tool_profile: ToolProfile,
pub temperature: Option<f32>,
pub max_output_tokens: Option<u32>,
pub metadata: BTreeMap<String, String>,
#[serde(default)]
pub provider_request_options: ProviderRequestOptions,
pub team: TeamConfig,
pub task: TaskConfig,
pub workspace: WorkspaceConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(alias = "context_compaction")]
pub compaction: CompactionConfig,
#[serde(default)]
pub tool_result_paging: Option<ToolResultPagingConfig>,
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
system: None,
tool_choice: Some(ToolChoice::default()),
tool_profile: ToolProfile::default(),
temperature: None,
max_output_tokens: Some(8192),
metadata: BTreeMap::new(),
provider_request_options: ProviderRequestOptions::default(),
team: TeamConfig::default(),
task: TaskConfig::default(),
workspace: WorkspaceConfig::default(),
memory: MemoryConfig::default(),
compaction: CompactionConfig::default(),
tool_result_paging: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use crate::provider::{ReasoningEffort, ReasoningOptions};
#[test]
fn a_known_context_window_sets_the_threshold_not_a_constant() {
let compaction = CompactionConfig::default();
assert_eq!(
compaction.auto_compact_threshold(Some(1_048_576)),
Some(786_432)
);
assert_eq!(
compaction.auto_compact_threshold(Some(64_000)),
Some(48_000)
);
}
#[test]
fn an_unknown_context_window_falls_back_to_the_absolute_threshold() {
let compaction = CompactionConfig::default();
assert_eq!(compaction.auto_compact_threshold(None), Some(50_000));
}
#[test]
fn under_the_threshold_trigger_a_missing_token_count_is_still_off() {
let compaction = CompactionConfig {
auto_compact_threshold_tokens: None,
..Default::default()
};
assert_eq!(
compaction.auto_compact_trigger,
AutoCompactTrigger::Thresholds
);
assert_eq!(compaction.auto_compact_threshold(Some(200_000)), None);
assert_eq!(compaction.auto_compact_threshold(None), None);
assert!(!compaction.auto_compact_enabled());
}
#[test]
fn the_window_share_trigger_compacts_on_the_window_and_never_on_a_constant() {
let compaction = CompactionConfig {
auto_compact_trigger: AutoCompactTrigger::WindowShareOnly,
..Default::default()
};
assert_eq!(
compaction.auto_compact_threshold(Some(200_000)),
Some(150_000)
);
assert_eq!(compaction.auto_compact_threshold(None), None);
assert!(compaction.auto_compact_enabled());
}
#[test]
fn the_window_share_trigger_ignores_the_absolute_number_entirely() {
let compaction = CompactionConfig {
auto_compact_trigger: AutoCompactTrigger::WindowShareOnly,
auto_compact_threshold_tokens: Some(9),
..Default::default()
};
assert_eq!(compaction.auto_compact_threshold(None), None);
assert_eq!(
compaction.auto_compact_threshold(Some(64_000)),
Some(48_000)
);
}
#[test]
fn the_window_share_trigger_without_a_percentage_never_compacts() {
let compaction = CompactionConfig {
auto_compact_trigger: AutoCompactTrigger::WindowShareOnly,
auto_compact_threshold_percent: None,
..Default::default()
};
assert_eq!(compaction.auto_compact_threshold(Some(200_000)), None);
assert_eq!(compaction.auto_compact_threshold(None), None);
assert!(!compaction.auto_compact_enabled());
}
#[test]
fn the_explicit_off_switch_survives_both_threshold_numbers() {
let compaction = CompactionConfig {
auto_compact_trigger: AutoCompactTrigger::Off,
auto_compact_threshold_tokens: Some(1),
auto_compact_threshold_percent: Some(1),
..Default::default()
};
assert_eq!(compaction.auto_compact_threshold(Some(200_000)), None);
assert_eq!(compaction.auto_compact_threshold(None), None);
assert!(!compaction.auto_compact_enabled());
}
#[test]
fn a_stored_config_without_the_trigger_field_keeps_the_pre_0_24_resolution() {
for tokens in [Some(50_000), None] {
for percent in [Some(75u8), None] {
let expected = CompactionConfig {
auto_compact_threshold_tokens: tokens,
auto_compact_threshold_percent: percent,
..Default::default()
};
let mut stored = serde_json::to_value(&expected).unwrap();
stored
.as_object_mut()
.unwrap()
.remove("auto_compact_trigger");
let loaded: CompactionConfig = serde_json::from_value(stored).unwrap();
assert_eq!(loaded.auto_compact_trigger, AutoCompactTrigger::Thresholds);
for window in [Some(200_000), Some(64_000), None] {
assert_eq!(
loaded.auto_compact_threshold(window),
expected.auto_compact_threshold(window),
"tokens={tokens:?} percent={percent:?} window={window:?}"
);
}
}
}
}
#[test]
fn the_trigger_round_trips_through_serde() {
for trigger in [
AutoCompactTrigger::Thresholds,
AutoCompactTrigger::Off,
AutoCompactTrigger::WindowShareOnly,
] {
let config = CompactionConfig {
auto_compact_trigger: trigger,
..Default::default()
};
let round_tripped: CompactionConfig =
serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
assert_eq!(round_tripped.auto_compact_trigger, trigger);
}
}
#[test]
fn clearing_the_percentage_pins_the_threshold_to_the_absolute_number() {
let compaction = CompactionConfig {
auto_compact_threshold_percent: None,
..Default::default()
};
assert_eq!(
compaction.auto_compact_threshold(Some(1_000_000)),
Some(50_000)
);
}
#[test]
fn compaction_keeps_every_tool_result_by_default() {
let compaction = CompactionConfig::default();
assert_eq!(compaction.keep_recent_tool_results, usize::MAX);
assert_eq!(compaction.projected_tool_result_budget, None);
assert!(
serde_json::to_value(compaction)
.unwrap()
.get("projected_tool_result_budget")
.is_none(),
"the disabled additive policy preserves the pre-0.22 JSON shape"
);
}
#[test]
fn compaction_config_without_the_budget_field_keeps_legacy_policy() {
let mut stored = serde_json::to_value(CompactionConfig {
keep_recent_tool_results: 2,
..Default::default()
})
.unwrap();
stored
.as_object_mut()
.unwrap()
.remove("projected_tool_result_budget");
let restored: CompactionConfig = serde_json::from_value(stored).unwrap();
assert_eq!(restored.keep_recent_tool_results, 2);
assert_eq!(restored.projected_tool_result_budget, None);
}
#[test]
fn projected_tool_result_budget_round_trips_zero_and_nonzero_limits() {
for projected_tool_result_budget in [
ProjectedToolResultBudget {
max_bytes: 0,
prioritize_recent_results: 0,
max_preview_bytes: 0,
},
ProjectedToolResultBudget {
max_bytes: 32 * 1024,
prioritize_recent_results: 4,
max_preview_bytes: 2048,
},
] {
let config = CompactionConfig {
keep_recent_tool_results: 1,
projected_tool_result_budget: Some(projected_tool_result_budget),
..Default::default()
};
let restored: CompactionConfig =
serde_json::from_value(serde_json::to_value(&config).unwrap()).unwrap();
assert_eq!(restored, config);
}
}
#[test]
fn tool_profile_defaults_to_allowing_everything() {
let profile = ToolProfile::default();
assert!(profile.allows("shell"));
assert!(profile.allows("files"));
}
#[test]
fn tool_profile_only_restricts_to_allowlist() {
let profile = ToolProfile::only(["shell", "files"]);
assert!(profile.allows("shell"));
assert!(profile.allows("files"));
assert!(!profile.allows("task"));
}
#[test]
fn tool_profile_hide_blocks_named_tools() {
let profile = ToolProfile::hide(["shell", "background_run"]);
assert!(!profile.allows("shell"));
assert!(!profile.allows("background_run"));
assert!(profile.allows("files"));
}
#[test]
fn tool_profile_respects_allowlist_and_hidden_overrides() {
let profile = ToolProfile {
allowed_tools: Some(["shell", "files"].into_iter().map(str::to_string).collect()),
hidden_tools: ["shell"].into_iter().map(str::to_string).collect(),
};
assert!(!profile.allows("shell"));
assert!(profile.allows("files"));
assert!(!profile.allows("task"));
}
#[test]
fn agent_config_deserializes_without_tool_profile_field() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": CompactionConfig::default()
}))
.expect("deserialize config without tool profile");
assert_eq!(config.tool_profile, ToolProfile::default());
}
#[test]
fn provider_request_options_default_to_disabled_tool_search() {
let options = ProviderRequestOptions::default();
assert_eq!(options.tool_search_mode, ToolSearchMode::Disabled);
assert_eq!(options.reasoning, None);
}
#[test]
fn agent_config_deserializes_without_tool_search_mode() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {
"responses": {
"parallel_tool_calls": true
}
},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": CompactionConfig::default()
}))
.expect("deserialize config without tool search mode");
assert_eq!(
config.provider_request_options.tool_search_mode,
ToolSearchMode::Disabled
);
assert_eq!(
config
.provider_request_options
.responses
.parallel_tool_calls,
Some(true)
);
}
#[test]
fn tool_result_paging_is_disabled_by_default() {
assert_eq!(AgentConfig::default().tool_result_paging, None);
}
#[test]
fn tool_result_paging_defaults_to_64_kib_threshold_and_32_kib_pages() {
let paging = ToolResultPagingConfig::default();
assert_eq!(paging.threshold_bytes, 64 * 1024);
assert_eq!(paging.page_bytes, 32 * 1024);
}
#[test]
fn agent_config_deserializes_without_tool_result_paging_field() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": CompactionConfig::default()
}))
.expect("deserialize config persisted before paging existed");
assert_eq!(config.tool_result_paging, None);
}
#[test]
fn agent_config_round_trips_tool_result_paging() {
let config = AgentConfig {
tool_result_paging: Some(ToolResultPagingConfig {
threshold_bytes: 4_096,
page_bytes: 1_024,
}),
..Default::default()
};
let restored: AgentConfig =
serde_json::from_value(serde_json::to_value(&config).expect("serialize config"))
.expect("deserialize config");
assert_eq!(restored.tool_result_paging, config.tool_result_paging);
}
#[test]
fn agent_config_deserializes_reasoning_options() {
let config: AgentConfig = serde_json::from_value(json!({
"system": null,
"tool_choice": serde_json::to_value(ToolChoice::Auto).expect("serialize tool choice"),
"temperature": null,
"max_output_tokens": 8192,
"metadata": {},
"provider_request_options": {
"reasoning": {
"effort": "high"
}
},
"team": TeamConfig::default(),
"task": TaskConfig::default(),
"workspace": WorkspaceConfig::default(),
"memory": MemoryConfig::default(),
"context_compaction": CompactionConfig::default()
}))
.expect("deserialize config with reasoning options");
assert_eq!(
config.provider_request_options.reasoning,
Some(ReasoningOptions {
effort: Some(ReasoningEffort::High),
summary: None,
})
);
}
}