feat: make autonomy action budget configurable (#2499)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
YOMXXX
2026-05-26 16:19:21 +05:30
committed by GitHub
co-authored by Steven Enamakel M3gA-Mind
parent 844a26c0c0
commit b616f48a04
11 changed files with 133 additions and 11 deletions
+3
View File
@@ -76,6 +76,9 @@ 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).
# Set to 0 to block all side-effecting tool actions entirely.
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)
+1
View File
@@ -47,6 +47,7 @@ export const LEGACY_METHOD_ALIASES: Record<string, CoreRpcMethod> = {
'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,
@@ -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.
+4
View File
@@ -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",
+26
View File
@@ -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();
+13
View File
@@ -1390,6 +1390,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::<u32>() {
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() {
+21
View File
@@ -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();
+30 -9
View File
@@ -2,20 +2,30 @@
use serde_json::json;
use crate::openhuman::config::Config;
use crate::openhuman::security::SecurityPolicy;
use crate::rpc::RpcOutcome;
pub fn security_policy_info() -> RpcOutcome<serde_json::Value> {
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<serde_json::Value> {
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 async fn load_and_get_security_policy_info() -> Result<RpcOutcome<serde_json::Value>, String> {
let config = crate::openhuman::config::ops::load_config_with_timeout().await?;
Ok(security_policy_info_for_config(&config))
}
#[cfg(test)]
@@ -26,7 +36,7 @@ mod tests {
fn security_policy_info_returns_all_documented_fields() {
// Locks in the JSON shape the JSON-RPC clients depend on —
// any rename / removal of a field would break the UI.
let outcome = security_policy_info();
let outcome = security_policy_info_for_config(&Config::default());
for key in [
"autonomy",
"workspace_only",
@@ -48,9 +58,10 @@ mod tests {
}
#[test]
fn security_policy_info_matches_default_policy_values() {
let outcome = security_policy_info();
let default = SecurityPolicy::default();
fn security_policy_info_matches_default_config_policy_values() {
let outcome = security_policy_info_for_config(&Config::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 +84,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));
}
}
+4 -1
View File
@@ -1794,7 +1794,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!(
+16
View File
@@ -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]
+13 -1
View File
@@ -40,7 +40,19 @@ pub fn schemas(function: &str) -> ControllerSchema {
}
fn handle_policy_info(_params: Map<String, Value>) -> 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<T: serde::Serialize>(outcome: RpcOutcome<T>) -> Result<Value, String> {