diff --git a/.env.example b/.env.example index f131b416d4..29fa903a7c 100644 --- a/.env.example +++ b/.env.example @@ -76,12 +76,14 @@ OPENHUMAN_CORE_BIN= # --------------------------------------------------------------------------- # Config overrides (override config.toml values at runtime) # --------------------------------------------------------------------------- +# [optional] Local safety cap for side-effecting tool actions in a rolling hour (default 20) +OPENHUMAN_MAX_ACTIONS_PER_HOUR=20 # [optional] Default model to use OPENHUMAN_MODEL= -# [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging) -OPENHUMAN_WORKSPACE= # [optional] Default: 0.7 OPENHUMAN_TEMPERATURE=0.7 +# [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging) +OPENHUMAN_WORKSPACE= # [optional] Language for background LLM artifacts such as memory-tree summaries, # entity-extraction reasons, and learning reflections. Accepts UI locale tags # such as zh-CN or a language name. Leave unset for default behavior. diff --git a/app/src/services/rpcMethods.ts b/app/src/services/rpcMethods.ts index faca15fb87..a5cb50490f 100644 --- a/app/src/services/rpcMethods.ts +++ b/app/src/services/rpcMethods.ts @@ -47,6 +47,7 @@ export const LEGACY_METHOD_ALIASES: Record = { 'openhuman.ping': CORE_RPC_METHODS.corePing, 'openhuman.set_browser_allow_all': CORE_RPC_METHODS.configSetBrowserAllowAll, 'openhuman.update_analytics_settings': CORE_RPC_METHODS.configUpdateAnalyticsSettings, + 'openhuman.update_autonomy_settings': CORE_RPC_METHODS.configUpdateAutonomySettings, 'openhuman.update_browser_settings': CORE_RPC_METHODS.configUpdateBrowserSettings, 'openhuman.update_composio_trigger_settings': CORE_RPC_METHODS.configUpdateComposioTriggerSettings, diff --git a/gitbooks/developing/architecture/agent-harness.md b/gitbooks/developing/architecture/agent-harness.md index f37cab39b0..8c3c9711d5 100644 --- a/gitbooks/developing/architecture/agent-harness.md +++ b/gitbooks/developing/architecture/agent-harness.md @@ -61,6 +61,7 @@ A **session** is the live conversation an `Agent` instance is running. The `Agen * The tool registry visible to the model. * A memory loader that hydrates relevant memories before each user message. * Per-turn budgets - max tool iterations, max payload size, max USD cost. +* Local action budget - a rolling hourly cap for side-effecting tool actions, read from `config.autonomy.max_actions_per_hour`. `Agent::turn(user_message)` is the hot path. In one turn it: @@ -234,6 +235,7 @@ Stop hooks fire **between** iterations of the tool-call loop. They're the policy * **Budget stop hook** - caps cumulative turn cost in USD using the per-iteration cost accumulator. * **Max-iterations stop hook** - caps iteration count from outside the agent's persistent config. +* **Action budget policy** - `SecurityPolicy` enforces `config.autonomy.max_actions_per_hour` for side-effecting tool operations. Users can tune it in Settings -> Advanced -> Agent autonomy, or operators can override it with `OPENHUMAN_MAX_ACTIONS_PER_HOUR`. A hook returning `Stop` aborts the loop with a clear reason the caller can surface to the user. Stop hooks are distinct from interrupts (next section): they're policy-driven, not user-driven. diff --git a/src/core/legacy_aliases.rs b/src/core/legacy_aliases.rs index 2f13d1f20f..bb9e476438 100644 --- a/src/core/legacy_aliases.rs +++ b/src/core/legacy_aliases.rs @@ -43,6 +43,10 @@ const LEGACY_ALIASES: &[(&str, &str)] = &[ "openhuman.update_analytics_settings", "openhuman.config_update_analytics_settings", ), + ( + "openhuman.update_autonomy_settings", + "openhuman.config_update_autonomy_settings", + ), ( "openhuman.update_browser_settings", "openhuman.config_update_browser_settings", diff --git a/src/openhuman/config/ops_tests.rs b/src/openhuman/config/ops_tests.rs index 1f5bb4f586..6997acd15b 100644 --- a/src/openhuman/config/ops_tests.rs +++ b/src/openhuman/config/ops_tests.rs @@ -620,6 +620,32 @@ async fn apply_memory_settings_updates_all_provided_fields() { ); } +#[tokio::test] +async fn apply_autonomy_settings_updates_action_budget() { + let tmp = tempdir().unwrap(); + let mut cfg = tmp_config(&tmp); + cfg.autonomy.max_actions_per_hour = 20; + + let outcome = apply_autonomy_settings( + &mut cfg, + AutonomySettingsPatch { + max_actions_per_hour: Some(64), + }, + ) + .await + .expect("apply autonomy settings"); + + assert_eq!(cfg.autonomy.max_actions_per_hour, 64); + assert_eq!( + outcome.value["config"]["autonomy"]["max_actions_per_hour"], + serde_json::json!(64) + ); + assert!(outcome + .logs + .iter() + .any(|l| l.contains("autonomy settings saved to"))); +} + #[tokio::test] async fn apply_memory_settings_ignores_unknown_memory_window_label() { let tmp = tempdir().unwrap(); diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index fb6966c7e6..c6b37602eb 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -1359,6 +1359,19 @@ impl Config { } } + if let Some(raw) = env.get("OPENHUMAN_MAX_ACTIONS_PER_HOUR") { + let trimmed = raw.trim(); + if !trimmed.is_empty() { + match trimmed.parse::() { + Ok(limit) => self.autonomy.max_actions_per_hour = limit, + Err(_) => tracing::warn!( + value = %raw, + "invalid OPENHUMAN_MAX_ACTIONS_PER_HOUR ignored; expected an unsigned integer" + ), + } + } + } + if let Some(language) = env.get("OPENHUMAN_OUTPUT_LANGUAGE") { let language = language.trim(); if !language.is_empty() { diff --git a/src/openhuman/config/schema/load_tests.rs b/src/openhuman/config/schema/load_tests.rs index 47c713f31c..d9716bd788 100644 --- a/src/openhuman/config/schema/load_tests.rs +++ b/src/openhuman/config/schema/load_tests.rs @@ -546,6 +546,27 @@ fn env_overlay_temperature_accepts_valid_and_ignores_out_of_range_or_garbage() { assert_eq!(cfg.default_temperature, 2.0); } +#[test] +fn env_overlay_autonomy_max_actions_per_hour_accepts_valid_u32() { + let mut cfg = Config::default(); + cfg.autonomy.max_actions_per_hour = 20; + + cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "64")); + assert_eq!(cfg.autonomy.max_actions_per_hour, 64); + + cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", " ")); + assert_eq!( + cfg.autonomy.max_actions_per_hour, 64, + "blank env value must leave the configured limit unchanged" + ); + + cfg.apply_env_overlay_with(&HashMapEnv::new().with("OPENHUMAN_MAX_ACTIONS_PER_HOUR", "NaN")); + assert_eq!( + cfg.autonomy.max_actions_per_hour, 64, + "invalid env value must leave the configured limit unchanged" + ); +} + #[test] fn env_overlay_output_language_accepts_non_empty_value() { let mut cfg = Config::default(); diff --git a/src/openhuman/security/ops.rs b/src/openhuman/security/ops.rs index fc05d36a32..e59c3206a7 100644 --- a/src/openhuman/security/ops.rs +++ b/src/openhuman/security/ops.rs @@ -2,20 +2,35 @@ use serde_json::json; +use crate::openhuman::config::Config; use crate::openhuman::security::SecurityPolicy; use crate::rpc::RpcOutcome; -pub fn security_policy_info() -> RpcOutcome { - let policy = SecurityPolicy::default(); - let payload = json!({ +fn policy_info_payload(policy: SecurityPolicy) -> serde_json::Value { + json!({ "autonomy": policy.autonomy, "workspace_only": policy.workspace_only, "allowed_commands": policy.allowed_commands, "max_actions_per_hour": policy.max_actions_per_hour, "require_approval_for_medium_risk": policy.require_approval_for_medium_risk, "block_high_risk_commands": policy.block_high_risk_commands, - }); - RpcOutcome::single_log(payload, "security_policy_info computed") + }) +} + +pub fn security_policy_info_for_config(config: &Config) -> RpcOutcome { + let policy = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir); + let payload = policy_info_payload(policy); + RpcOutcome::single_log(payload, "security_policy_info computed from active config") +} + +pub fn security_policy_info() -> RpcOutcome { + let config = Config::default(); + security_policy_info_for_config(&config) +} + +pub async fn load_and_get_security_policy_info() -> Result, String> { + let config = crate::openhuman::config::ops::load_config_with_timeout().await?; + Ok(security_policy_info_for_config(&config)) } #[cfg(test)] @@ -48,9 +63,10 @@ mod tests { } #[test] - fn security_policy_info_matches_default_policy_values() { + fn security_policy_info_matches_default_config_policy_values() { let outcome = security_policy_info(); - let default = SecurityPolicy::default(); + let config = Config::default(); + let default = SecurityPolicy::from_config(&config.autonomy, &config.workspace_dir); assert_eq!(outcome.value["autonomy"], json!(default.autonomy)); assert_eq!( outcome.value["allowed_commands"], @@ -73,4 +89,14 @@ mod tests { json!(default.require_approval_for_medium_risk) ); } + + #[test] + fn security_policy_info_reflects_configured_action_budget() { + let mut config = crate::openhuman::config::Config::default(); + config.autonomy.max_actions_per_hour = 77; + + let outcome = security_policy_info_for_config(&config); + + assert_eq!(outcome.value["max_actions_per_hour"], json!(77)); + } } diff --git a/src/openhuman/security/policy.rs b/src/openhuman/security/policy.rs index a49357116b..3feaf5c336 100644 --- a/src/openhuman/security/policy.rs +++ b/src/openhuman/security/policy.rs @@ -1037,7 +1037,10 @@ impl SecurityPolicy { "[openhuman:policy] Operation '{}' blocked: rate limit exceeded", operation_name ); - return Err("Rate limit exceeded: action budget exhausted".to_string()); + return Err(format!( + "Rate limit exceeded: action budget exhausted ({} actions/hour). Increase the limit in Settings -> Advanced -> Agent autonomy or wait for the rolling one-hour window to refill.", + self.max_actions_per_hour + )); } log::debug!( diff --git a/src/openhuman/security/policy_tests.rs b/src/openhuman/security/policy_tests.rs index a2dec272cd..41dec2974a 100644 --- a/src/openhuman/security/policy_tests.rs +++ b/src/openhuman/security/policy_tests.rs @@ -79,6 +79,22 @@ fn enforce_tool_operation_act_uses_rate_budget() { assert!(err.contains("Rate limit exceeded")); } +#[test] +fn action_budget_error_mentions_limit_and_settings() { + let p = SecurityPolicy { + max_actions_per_hour: 0, + ..default_policy() + }; + + let err = p + .enforce_tool_operation(ToolOperation::Act, "write_file") + .unwrap_err(); + + assert!(err.contains("Rate limit exceeded: action budget exhausted")); + assert!(err.contains("0 actions/hour")); + assert!(err.contains("Settings -> Advanced -> Agent autonomy")); +} + // -- is_command_allowed ------------------------------------------- #[test] diff --git a/src/openhuman/security/schemas.rs b/src/openhuman/security/schemas.rs index 446ff74acb..6047779c45 100644 --- a/src/openhuman/security/schemas.rs +++ b/src/openhuman/security/schemas.rs @@ -40,7 +40,19 @@ pub fn schemas(function: &str) -> ControllerSchema { } fn handle_policy_info(_params: Map) -> ControllerFuture { - Box::pin(async { to_json(crate::openhuman::security::rpc::security_policy_info()) }) + Box::pin(async { + log::debug!("[security][rpc] policy_info enter"); + match crate::openhuman::security::rpc::load_and_get_security_policy_info().await { + Ok(outcome) => { + log::debug!("[security][rpc] policy_info ok"); + to_json(outcome) + } + Err(err) => { + log::warn!("[security][rpc] policy_info failed: {err}"); + Err(err) + } + } + }) } fn to_json(outcome: RpcOutcome) -> Result {