mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Steven Enamakel
M3gA-Mind
parent
844a26c0c0
commit
b616f48a04
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user