1use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14
15use crate::deployment::DeploymentGrade;
16
17#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
27pub struct FeatureFlags {
28 pub global_chat: bool,
34 pub notifications: bool,
36 pub evals: bool,
38 pub app_budgets: bool,
41 pub agent_versions: bool,
44 pub voice: bool,
46 pub agent_delegation: bool,
50 pub observers: bool,
52 pub public_chat: bool,
56}
57
58#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
66#[serde(transparent)]
67pub struct FeatureFlagMap(pub BTreeMap<String, bool>);
68
69impl From<&FeatureFlags> for FeatureFlagMap {
70 fn from(flags: &FeatureFlags) -> Self {
71 flags.to_map()
72 }
73}
74
75impl From<FeatureFlags> for FeatureFlagMap {
76 fn from(flags: FeatureFlags) -> Self {
77 flags.to_map()
78 }
79}
80
81#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
83pub struct FeatureFlagDefinition {
84 pub name: &'static str,
86 pub label: &'static str,
88 pub description: &'static str,
90 pub experimental: bool,
92}
93
94pub const API_FEATURE_FLAG_DEFINITIONS: &[FeatureFlagDefinition] = &[
96 FeatureFlagDefinition {
97 name: "global_chat",
98 label: "Platform Chat",
99 description: "Adds a personal Platform Chat assistant you can open from the sidebar or the \
100 /chat page anywhere in the app. It's a quick scratchpad to talk to your agents \
101 without first setting up a dedicated app or channel. Turning it off disables the \
102 whole feature — the chat page, the sidebar entry, and its session and voice APIs all \
103 become unavailable.",
104 experimental: true,
105 },
106 FeatureFlagDefinition {
107 name: "notifications",
108 label: "Notifications",
109 description: "Turns on the in-app notification bell, toasts, and live updates. You get \
110 alerted in real time when something you care about happens, instead of refreshing \
111 or checking back manually.",
112 experimental: true,
113 },
114 FeatureFlagDefinition {
115 name: "evals",
116 label: "Evals",
117 description: "Lets you define and run behavioral evals against your agents. Use it to \
118 confirm an agent responds the way you expect and to catch regressions as you change \
119 prompts or models.",
120 experimental: true,
121 },
122 FeatureFlagDefinition {
123 name: "app_budgets",
124 label: "App budgets",
125 description: "Adds spending limits scoped to individual apps and channels, with automatic \
126 resets on a schedule. It helps you cap and control costs so a single app or channel \
127 can't run away with your usage.",
128 experimental: true,
129 },
130 FeatureFlagDefinition {
131 name: "agent_versions",
132 label: "Agent versions",
133 description: "Captures immutable snapshots of your agents so you can fork, roll back, and \
134 pin apps to a specific version. This gives you a safety net to experiment freely \
135 and return to a known-good agent at any time.",
136 experimental: true,
137 },
138 FeatureFlagDefinition {
139 name: "voice",
140 label: "Voice",
141 description: "Enables realtime voice in chat with microphone controls. You can talk to \
142 your agents and hear responses instead of typing, for a hands-free conversation.",
143 experimental: true,
144 },
145 FeatureFlagDefinition {
146 name: "observers",
147 label: "Observers",
148 description: "Runs automatic online scoring on your production sessions. It continuously \
149 evaluates live conversations so you can monitor quality on real traffic without \
150 manually reviewing each one.",
151 experimental: true,
152 },
153 FeatureFlagDefinition {
154 name: "public_chat",
155 label: "Public Chat",
156 description: "Isolated, public-facing chat web app and the public_chat channel.",
157 experimental: true,
158 },
159];
160
161impl FeatureFlags {
162 pub fn for_org(system: &Self, org_enabled: &std::collections::HashMap<String, bool>) -> Self {
167 let opt_in = |name: &str, system_on: bool| -> bool {
168 system_on && org_enabled.get(name).copied().unwrap_or(false)
169 };
170 Self {
171 global_chat: opt_in("global_chat", system.global_chat),
172 notifications: opt_in("notifications", system.notifications),
173 evals: opt_in("evals", system.evals),
174 app_budgets: opt_in("app_budgets", system.app_budgets),
175 agent_versions: opt_in("agent_versions", system.agent_versions),
176 voice: opt_in("voice", system.voice),
177 agent_delegation: opt_in("agent_delegation", system.agent_delegation),
178 observers: opt_in("observers", system.observers),
179 public_chat: opt_in("public_chat", system.public_chat),
180 }
181 }
182
183 pub fn from_env(grade: &DeploymentGrade) -> Self {
185 Self {
186 global_chat: experimental_flag("FEATURE_GLOBAL_CHAT", grade),
187 notifications: experimental_flag("FEATURE_NOTIFICATIONS", grade),
188 evals: experimental_flag("FEATURE_EVALS", grade),
189 app_budgets: experimental_flag("FEATURE_APP_BUDGETS", grade),
190 agent_versions: experimental_flag("FEATURE_AGENT_VERSIONS", grade),
191 voice: experimental_flag("FEATURE_VOICE", grade),
192 agent_delegation: experimental_flag("FEATURE_AGENT_DELEGATION", grade),
193 observers: experimental_flag("FEATURE_OBSERVERS", grade),
194 public_chat: experimental_flag("FEATURE_PUBLIC_CHAT", grade),
195 }
196 }
197
198 pub fn current() -> Self {
201 Self::from_env(&DeploymentGrade::from_env())
202 }
203
204 pub fn to_map(&self) -> FeatureFlagMap {
211 FeatureFlagMap(BTreeMap::from([
212 ("global_chat".to_string(), self.global_chat),
213 ("notifications".to_string(), self.notifications),
214 ("evals".to_string(), self.evals),
215 ("app_budgets".to_string(), self.app_budgets),
216 ("agent_versions".to_string(), self.agent_versions),
217 ("voice".to_string(), self.voice),
218 ("agent_delegation".to_string(), self.agent_delegation),
219 ("observers".to_string(), self.observers),
220 ("public_chat".to_string(), self.public_chat),
221 ]))
222 }
223
224 pub fn is_enabled(&self, flag: &str) -> bool {
226 match flag {
227 "global_chat" => self.global_chat,
228 "notifications" => self.notifications,
229 "evals" => self.evals,
230 "app_budgets" => self.app_budgets,
231 "agent_versions" => self.agent_versions,
232 "voice" => self.voice,
233 "agent_delegation" => self.agent_delegation,
234 "observers" => self.observers,
235 "public_chat" => self.public_chat,
236 _ => false,
237 }
238 }
239
240 #[cfg(test)]
242 pub fn all_enabled() -> Self {
243 Self {
244 global_chat: true,
245 notifications: true,
246 evals: true,
247 app_budgets: true,
248 agent_versions: true,
249 voice: true,
250 agent_delegation: true,
251 observers: true,
252 public_chat: true,
253 }
254 }
255}
256
257#[derive(Debug, Default, Clone, PartialEq, Eq)]
261pub struct InternalFeatureFlags {
262 pub docker_capability: bool,
265 pub container_sandbox: bool,
271 pub session_sandbox: bool,
274 pub machine_payments: bool,
279 pub lua: bool,
283}
284
285impl InternalFeatureFlags {
286 pub fn from_env() -> Self {
288 let docker_capability = standard_flag("FEATURE_DOCKER_CAPABILITY", false);
289
290 Self {
291 docker_capability,
292 container_sandbox: standard_flag("FEATURE_CONTAINER_SANDBOX", docker_capability),
293 session_sandbox: standard_flag("FEATURE_SESSION_SANDBOX", false),
294 machine_payments: standard_flag("FEATURE_MACHINE_PAYMENTS", false),
295 lua: standard_flag("FEATURE_LUA", false),
296 }
297 }
298
299 pub fn is_enabled(&self, flag: &str) -> bool {
301 match flag {
302 "docker_capability" => self.docker_capability,
303 "container_sandbox" => self.container_sandbox,
304 "session_sandbox" => self.session_sandbox,
305 "machine_payments" => self.machine_payments,
306 "lua" => self.lua,
307 _ => false,
308 }
309 }
310}
311
312fn experimental_flag(env_var: &str, grade: &DeploymentGrade) -> bool {
316 if let Ok(val) = std::env::var(env_var) {
317 return val == "true" || val == "1";
318 }
319 grade.experimental_features_enabled()
320}
321
322fn standard_flag(env_var: &str, default: bool) -> bool {
326 std::env::var(env_var)
327 .map(|v| v == "true" || v == "1")
328 .unwrap_or(default)
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
337
338 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
339 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
340 }
341
342 fn restore_env(key: &str, prev: Option<String>) {
344 match prev {
345 Some(value) => unsafe { std::env::set_var(key, value) },
346 None => unsafe { std::env::remove_var(key) },
347 }
348 }
349
350 #[test]
351 fn test_default_flags() {
352 let flags = FeatureFlags::default();
353 assert!(!flags.global_chat);
354 assert!(!flags.notifications);
355 }
356
357 #[test]
361 fn test_experimental_enabled_in_dev() {
362 let _lock = lock_env();
363 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
364 unsafe { std::env::remove_var("FEATURE_EVALS") };
365 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
366 assert!(flags.global_chat);
367 assert!(flags.evals);
368 }
369
370 #[test]
371 fn test_experimental_disabled_in_prod() {
372 let _lock = lock_env();
373 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
374 unsafe { std::env::remove_var("FEATURE_EVALS") };
375 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
376 assert!(!flags.global_chat);
377 assert!(!flags.evals);
378 }
379
380 #[test]
381 fn test_env_override_enables_in_prod() {
382 let _lock = lock_env();
383 unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "true") };
384 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
385 assert!(flags.global_chat);
386 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
387 }
388
389 #[test]
390 fn test_env_override_disables_in_dev() {
391 let _lock = lock_env();
392 unsafe { std::env::set_var("FEATURE_GLOBAL_CHAT", "false") };
393 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
394 assert!(!flags.global_chat);
395 unsafe { std::env::remove_var("FEATURE_GLOBAL_CHAT") };
396 }
397
398 #[test]
399 fn test_is_enabled_dynamic() {
400 let flags = FeatureFlags {
401 global_chat: true,
402 notifications: true,
403 evals: true,
404 app_budgets: true,
405 agent_versions: true,
406 voice: true,
407 agent_delegation: true,
408 observers: true,
409 public_chat: true,
410 };
411 assert!(flags.is_enabled("global_chat"));
412 assert!(flags.is_enabled("notifications"));
413 assert!(flags.is_enabled("evals"));
414 assert!(flags.is_enabled("app_budgets"));
415 assert!(flags.is_enabled("agent_versions"));
416 assert!(flags.is_enabled("voice"));
417 assert!(flags.is_enabled("agent_delegation"));
418 assert!(flags.is_enabled("observers"));
419 assert!(flags.is_enabled("public_chat"));
420 assert!(!flags.is_enabled("nonexistent"));
421 }
422
423 #[test]
424 fn test_serialization() {
425 let flags = FeatureFlags {
426 global_chat: true,
427 notifications: true,
428 evals: true,
429 app_budgets: true,
430 agent_versions: true,
431 voice: true,
432 agent_delegation: true,
433 observers: true,
434 public_chat: true,
435 };
436 let json = serde_json::to_string(&flags).unwrap();
437 assert!(json.contains("\"global_chat\":true"));
438 assert!(json.contains("\"notifications\":true"));
439 assert!(json.contains("\"app_budgets\":true"));
440 assert!(json.contains("\"agent_versions\":true"));
441 assert!(json.contains("\"voice\":true"));
442 assert!(json.contains("\"agent_delegation\":true"));
443 assert!(json.contains("\"observers\":true"));
444
445 let parsed: FeatureFlags = serde_json::from_str(&json).unwrap();
446 assert_eq!(flags, parsed);
447 }
448
449 #[test]
450 fn test_to_map_matches_serialized_flags() {
451 let flags = FeatureFlags::all_enabled();
456 let typed: serde_json::Value = serde_json::to_value(&flags).unwrap();
457 let map: serde_json::Value = serde_json::to_value(flags.to_map()).unwrap();
458 assert_eq!(typed, map);
459
460 let default_typed: serde_json::Value =
461 serde_json::to_value(FeatureFlags::default()).unwrap();
462 let default_map: serde_json::Value =
463 serde_json::to_value(FeatureFlags::default().to_map()).unwrap();
464 assert_eq!(default_typed, default_map);
465 }
466
467 #[test]
468 fn test_agent_delegation_enabled_in_dev() {
469 let _lock = lock_env();
470 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
471 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
472 assert!(flags.agent_delegation);
473 }
474
475 #[test]
476 fn test_agent_delegation_disabled_in_prod() {
477 let _lock = lock_env();
478 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
479 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
480 assert!(!flags.agent_delegation);
481 }
482
483 #[test]
484 fn test_agent_delegation_env_override_in_prod() {
485 let _lock = lock_env();
486 unsafe { std::env::set_var("FEATURE_AGENT_DELEGATION", "true") };
487 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
488 assert!(flags.agent_delegation);
489 unsafe { std::env::remove_var("FEATURE_AGENT_DELEGATION") };
490 }
491
492 #[test]
493 fn test_standard_flag() {
494 let _lock = lock_env();
495 unsafe { std::env::remove_var("FEATURE_TEST_STD") };
496 assert!(!standard_flag("FEATURE_TEST_STD", false));
497 assert!(standard_flag("FEATURE_TEST_STD", true));
498
499 unsafe { std::env::set_var("FEATURE_TEST_STD", "1") };
500 assert!(standard_flag("FEATURE_TEST_STD", false));
501 unsafe { std::env::remove_var("FEATURE_TEST_STD") };
502 }
503
504 #[test]
505 fn test_notifications_enabled_in_dev() {
506 let _lock = lock_env();
507 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
508 let flags = FeatureFlags::from_env(&DeploymentGrade::Dev);
509 assert!(flags.notifications);
510 }
511
512 #[test]
513 fn test_notifications_disabled_in_prod() {
514 let _lock = lock_env();
515 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
516 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
517 assert!(!flags.notifications);
518 }
519
520 #[test]
521 fn test_for_org_requires_system_and_opt_in() {
522 let system = FeatureFlags {
523 global_chat: true,
524 evals: true,
525 ..FeatureFlags::default()
526 };
527 let mut org = std::collections::HashMap::new();
528 org.insert("global_chat".to_string(), true);
529 let effective = FeatureFlags::for_org(&system, &org);
530 assert!(effective.global_chat);
531 assert!(!effective.evals);
532
533 let effective_none = FeatureFlags::for_org(&system, &std::collections::HashMap::new());
534 assert!(!effective_none.global_chat);
535 }
536
537 #[test]
538 fn test_for_org_cannot_enable_when_system_off() {
539 let system = FeatureFlags::default();
540 let mut org = std::collections::HashMap::new();
541 org.insert("global_chat".to_string(), true);
542 let effective = FeatureFlags::for_org(&system, &org);
543 assert!(!effective.global_chat);
544 }
545
546 #[test]
547 fn test_notifications_respects_env_override() {
548 let _lock = lock_env();
549 unsafe { std::env::set_var("FEATURE_NOTIFICATIONS", "true") };
550 let flags = FeatureFlags::from_env(&DeploymentGrade::Prod);
551 assert!(flags.notifications);
552 unsafe { std::env::remove_var("FEATURE_NOTIFICATIONS") };
553 }
554
555 #[test]
560 fn test_internal_default_flags() {
561 let flags = InternalFeatureFlags::default();
562 assert!(!flags.docker_capability);
563 assert!(!flags.container_sandbox);
564 assert!(!flags.session_sandbox);
565 assert!(!flags.machine_payments);
566 }
567
568 #[test]
569 fn test_docker_capability_flag_disabled_by_default_in_dev() {
570 let _lock = lock_env();
571 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
572 let flags = InternalFeatureFlags::from_env();
573 assert!(
574 !flags.docker_capability,
575 "docker_capability should be disabled by default even in dev"
576 );
577 }
578
579 #[test]
580 fn test_docker_capability_flag_enabled_by_env_override() {
581 let _lock = lock_env();
582 unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
583 let flags = InternalFeatureFlags::from_env();
584 assert!(flags.docker_capability);
585 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
586 }
587
588 #[test]
589 fn test_container_sandbox_flag_enabled_by_env_override() {
590 let _lock = lock_env();
591 unsafe { std::env::set_var("FEATURE_CONTAINER_SANDBOX", "true") };
592 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
593 let flags = InternalFeatureFlags::from_env();
594 assert!(flags.container_sandbox);
595 unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
596 }
597
598 #[test]
599 fn test_container_sandbox_flag_falls_back_to_legacy_docker_flag() {
600 let _lock = lock_env();
601 unsafe { std::env::remove_var("FEATURE_CONTAINER_SANDBOX") };
602 unsafe { std::env::set_var("FEATURE_DOCKER_CAPABILITY", "true") };
603 let flags = InternalFeatureFlags::from_env();
604 assert!(flags.container_sandbox);
605 unsafe { std::env::remove_var("FEATURE_DOCKER_CAPABILITY") };
606 }
607
608 #[test]
609 fn test_internal_is_enabled_dynamic() {
610 let flags = InternalFeatureFlags {
611 docker_capability: true,
612 container_sandbox: true,
613 session_sandbox: true,
614 machine_payments: true,
615 lua: true,
616 };
617 assert!(flags.is_enabled("docker_capability"));
618 assert!(flags.is_enabled("container_sandbox"));
619 assert!(flags.is_enabled("session_sandbox"));
620 assert!(flags.is_enabled("machine_payments"));
621 assert!(flags.is_enabled("lua"));
622 assert!(!flags.is_enabled("nonexistent"));
623 }
624
625 #[test]
626 fn test_machine_payments_disabled_by_default() {
627 let _lock = lock_env();
628 let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
629 unsafe { std::env::remove_var("FEATURE_MACHINE_PAYMENTS") };
630 let flags = InternalFeatureFlags::from_env();
631 assert!(
632 !flags.machine_payments,
633 "machine_payments should be disabled by default on all envs"
634 );
635 restore_env("FEATURE_MACHINE_PAYMENTS", prev);
636 }
637
638 #[test]
639 fn test_machine_payments_enabled_by_env_override() {
640 let _lock = lock_env();
641 let prev = std::env::var("FEATURE_MACHINE_PAYMENTS").ok();
642 unsafe { std::env::set_var("FEATURE_MACHINE_PAYMENTS", "true") };
643 let flags = InternalFeatureFlags::from_env();
644 assert!(flags.machine_payments);
645 restore_env("FEATURE_MACHINE_PAYMENTS", prev);
646 }
647
648 #[test]
649 fn test_session_sandbox_flag_enabled_by_env_override() {
650 let _lock = lock_env();
651 unsafe { std::env::set_var("FEATURE_SESSION_SANDBOX", "true") };
652 let flags = InternalFeatureFlags::from_env();
653 assert!(flags.session_sandbox);
654 unsafe { std::env::remove_var("FEATURE_SESSION_SANDBOX") };
655 }
656}