Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions app/src/services/rpcMethods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions gitbooks/developing/architecture/agent-harness.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions src/core/legacy_aliases.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 26 additions & 0 deletions src/openhuman/config/ops_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
13 changes: 13 additions & 0 deletions src/openhuman/config/schema/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<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() {
Expand Down
21 changes: 21 additions & 0 deletions src/openhuman/config/schema/load_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
40 changes: 33 additions & 7 deletions src/openhuman/security/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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 fn security_policy_info() -> RpcOutcome<serde_json::Value> {
let config = Config::default();
security_policy_info_for_config(&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)]
Expand Down Expand Up @@ -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"],
Expand All @@ -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));
}
}
5 changes: 4 additions & 1 deletion src/openhuman/security/policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
16 changes: 16 additions & 0 deletions src/openhuman/security/policy_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
14 changes: 13 additions & 1 deletion src/openhuman/security/schemas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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> {
Expand Down
Loading