mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Prune obsolete config API keys (#739)
* feat(memory_recipes): introduce memory recipes module for source-type-specific ingestion - Added a new `memory_recipes` module that includes functionality for handling various source types, such as email and chat, with dedicated recipes for ingestion. - Implemented core types and the `Recipe` trait to facilitate the ingestion process, allowing for structured handling of raw content and metadata. - Created specific recipes for email and chat, enhancing the ability to process and normalize content before storing it in memory. - Updated the core controller registration to include new endpoints for memory recipes, enabling JSON-RPC interactions for ingesting content and listing available recipes. - Enhanced task management by integrating task storage and query helpers, allowing for better tracking and management of action items extracted during ingestion. These changes significantly improve the ingestion capabilities of the application, providing a robust framework for handling diverse content types. * refactor: remove API key references from configuration and schema - Eliminated the `api_key` field from various configuration schemas and related structures, including `Config`, `ModelSettingsPatch`, and `ComposioConfig`, to streamline the configuration process. - Updated the handling of API keys in the application to rely solely on session JWTs, enhancing security and simplifying the authentication flow. - Adjusted related documentation and comments to reflect the removal of the API key, ensuring clarity in the new authentication approach. - Refactored multiple components and functions to remove dependencies on the API key, improving code maintainability and reducing complexity. These changes significantly enhance the security posture of the application by removing reliance on static API keys and promoting the use of session-based authentication. * chore: update .env.example and documentation for API key removal - Removed references to `OPENHUMAN_API_KEY` from the `.env.example` file to reflect recent changes in the authentication approach. - Updated documentation in `AGENTS.md` and `CLAUDE.md` to clarify that `OPENHUMAN_API_URL` now overrides `config.api_url`, ensuring consistency across configuration references. - Bumped the version in `Cargo.lock` to 0.52.23 to align with the latest changes. These updates enhance clarity in configuration management and documentation following the removal of static API keys. * fix: address config pruning review comments * feat(tests): add validation tests for SearchResponse deserialization - Introduced new tests to ensure that the SearchResponse struct correctly rejects JSON inputs missing required fields: searchId, results, and costUsd. - Updated DelegateTool instantiation to remove the unused fallback_credential parameter, simplifying the constructor and related tests. These changes enhance the robustness of the SearchResponse handling and improve code clarity by removing unnecessary parameters. * refactor: remove api_key params from provider factory signatures The OpenHuman backend now only uses the app-session JWT for auth — the api_key parameter was ignored (as _api_key) after the config pruning. Drop it from the signatures entirely instead of leaving dead None arguments at every call site. Also removes the unused ChannelContext.api_key field. * refactor: remove api_key/composio_key params from factory signatures Every caller was passing None after the config pruning: - create_embedding_provider / create_memory* — the api_key param was only threaded to the openai / custom embedding branches, but no caller has ever supplied a non-None value since the pruning. Embeddings use an empty key; the param is gone. - all_tools / all_tools_with_runtime — both composio_key and composio_entity_id only fed the ComposioTool registration block, which was already dead after the JWT migration. Drop both args and the block; the modern path is all_composio_agent_tools via build_composio_client(config). * style: apply cargo fmt
This commit is contained in:
+1
-18
@@ -52,8 +52,6 @@ OPENHUMAN_CORE_BIN=
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config overrides (override config.toml values at runtime)
|
||||
# ---------------------------------------------------------------------------
|
||||
# [required] API key for the LLM provider
|
||||
OPENHUMAN_API_KEY=
|
||||
# [optional] Default model to use
|
||||
OPENHUMAN_MODEL=
|
||||
# [optional] Workspace directory (default: ~/.openhuman or ~/.openhuman-staging when OPENHUMAN_APP_ENV=staging)
|
||||
@@ -76,27 +74,12 @@ OPENHUMAN_REASONING_ENABLED=
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web search
|
||||
# ---------------------------------------------------------------------------
|
||||
# Web search is always enabled — no opt-in flag. Configure the
|
||||
# provider / API keys / budgets below.
|
||||
# [optional] Search provider name
|
||||
OPENHUMAN_WEB_SEARCH_PROVIDER=
|
||||
# [optional] Required if web search provider is Brave
|
||||
OPENHUMAN_BRAVE_API_KEY=
|
||||
# Web search is always enabled — no opt-in flag. Configure result budgets below.
|
||||
# [optional] Default: 5
|
||||
OPENHUMAN_WEB_SEARCH_MAX_RESULTS=5
|
||||
# [optional] Default: 10
|
||||
OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS=10
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Storage
|
||||
# ---------------------------------------------------------------------------
|
||||
# [optional] Storage backend (default: SQLite)
|
||||
OPENHUMAN_STORAGE_PROVIDER=
|
||||
# [optional] Database connection URL
|
||||
OPENHUMAN_STORAGE_DB_URL=
|
||||
# [optional] Connection timeout in seconds
|
||||
OPENHUMAN_STORAGE_CONNECT_TIMEOUT_SECS=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Proxy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -84,7 +84,7 @@ Environment variables are documented in two `.env.example` files:
|
||||
|
||||
**Frontend config** is centralized in [`app/src/utils/config.ts`](app/src/utils/config.ts). All `VITE_*` env vars should be read there and re-exported — do not read `import.meta.env` directly in other files.
|
||||
|
||||
**Rust config** uses a TOML-based `Config` struct (`src/openhuman/config/schema/types.rs`) with env var overrides applied in `src/openhuman/config/schema/load.rs`. Env vars override config file values at runtime (e.g. `OPENHUMAN_API_KEY` overrides `config.api_key`).
|
||||
**Rust config** uses a TOML-based `Config` struct (`src/openhuman/config/schema/types.rs`) with env var overrides applied in `src/openhuman/config/schema/load.rs`. Env vars override config file values at runtime (e.g. `OPENHUMAN_API_URL` overrides `config.api_url`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ Environment variables are documented in two `.env.example` files:
|
||||
|
||||
**Frontend config** is centralized in [`app/src/utils/config.ts`](app/src/utils/config.ts). All `VITE_*` env vars should be read there and re-exported — do not read `import.meta.env` directly in other files.
|
||||
|
||||
**Rust config** uses a TOML-based `Config` struct (`src/openhuman/config/schema/types.rs`) with env var overrides applied in `src/openhuman/config/schema/load.rs`. Env vars override config file values at runtime (e.g. `OPENHUMAN_API_KEY` overrides `config.api_key`).
|
||||
**Rust config** uses a TOML-based `Config` struct (`src/openhuman/config/schema/types.rs`) with env var overrides applied in `src/openhuman/config/schema/load.rs`. Env vars override config file values at runtime (e.g. `OPENHUMAN_API_URL` overrides `config.api_url`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -776,12 +776,6 @@
|
||||
"description": "Update model and API connection settings.",
|
||||
"function": "update_model_settings",
|
||||
"inputs": [
|
||||
{
|
||||
"comment": "Provider API key.",
|
||||
"name": "api_key",
|
||||
"required": false,
|
||||
"ty": { "Option": "String" }
|
||||
},
|
||||
{
|
||||
"comment": "Backend API URL.",
|
||||
"name": "api_url",
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface ConfigSnapshot {
|
||||
}
|
||||
|
||||
export interface ModelSettingsUpdate {
|
||||
api_key?: string | null;
|
||||
api_url?: string | null;
|
||||
default_model?: string | null;
|
||||
default_temperature?: number | null;
|
||||
|
||||
@@ -556,32 +556,17 @@ impl Agent {
|
||||
&config.embedding_routes,
|
||||
Some(&config.storage.provider.config),
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
|
||||
let composio_key = if config.composio.enabled {
|
||||
config.composio.api_key.as_deref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let composio_entity_id = if config.composio.enabled {
|
||||
Some(config.composio.entity_id.as_str())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let mut tools = tools::all_tools_with_runtime(
|
||||
Arc::new(config.clone()),
|
||||
&security,
|
||||
runtime,
|
||||
memory.clone(),
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&config.workspace_dir,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
config,
|
||||
);
|
||||
|
||||
@@ -632,7 +617,6 @@ impl Agent {
|
||||
};
|
||||
|
||||
let provider: Box<dyn Provider> = providers::create_intelligent_routing_provider(
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
config,
|
||||
&provider_runtime_options,
|
||||
@@ -744,7 +728,6 @@ impl Agent {
|
||||
== crate::openhuman::config::ReflectionSource::Cloud
|
||||
{
|
||||
Some(Arc::from(providers::create_routed_provider(
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
|
||||
@@ -432,7 +432,7 @@ mod tests {
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap(),
|
||||
);
|
||||
|
||||
Agent::builder()
|
||||
|
||||
@@ -154,9 +154,8 @@ fn build_minimal_agent_with_definition_name(definition_name: Option<&str>) -> Ag
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let mut builder = Agent::builder()
|
||||
.provider(provider)
|
||||
@@ -260,9 +259,8 @@ async fn turn_without_tools_returns_text() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
@@ -305,9 +303,8 @@ async fn turn_with_native_dispatcher_handles_tool_results_variant() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
@@ -353,9 +350,8 @@ async fn turn_with_native_dispatcher_persists_fallback_tool_calls() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider(provider)
|
||||
@@ -439,9 +435,8 @@ async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
// Tools include SpawnSubagentTool so the parent can call it.
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(SpawnSubagentTool::new())];
|
||||
@@ -549,9 +544,8 @@ async fn turn_dispatches_spawn_subagent_in_fork_mode() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let tools: Vec<Box<dyn Tool>> = vec![Box::new(SpawnSubagentTool::new())];
|
||||
|
||||
@@ -637,9 +631,8 @@ async fn system_prompt_and_model_are_byte_stable_across_turns() {
|
||||
backend: "none".into(),
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
);
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap());
|
||||
|
||||
let mut agent = Agent::builder()
|
||||
.provider_arc(provider.clone() as Arc<dyn Provider>)
|
||||
|
||||
@@ -1549,7 +1549,7 @@ mod tests {
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap(),
|
||||
);
|
||||
|
||||
let mut builder = Agent::builder()
|
||||
@@ -1587,7 +1587,7 @@ mod tests {
|
||||
..crate::openhuman::config::MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> = Arc::from(
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path, None).unwrap(),
|
||||
crate::openhuman::memory::create_memory(&memory_cfg, &workspace_path).unwrap(),
|
||||
);
|
||||
|
||||
Agent::builder()
|
||||
|
||||
@@ -247,7 +247,7 @@ fn make_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
|
||||
backend: "none".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem = Arc::from(memory::create_memory(&cfg, tmp.path(), None).unwrap());
|
||||
let mem = Arc::from(memory::create_memory(&cfg, tmp.path()).unwrap());
|
||||
(mem, tmp)
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ fn make_sqlite_memory() -> (Arc<dyn Memory>, tempfile::TempDir) {
|
||||
backend: "sqlite".into(),
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem = Arc::from(memory::create_memory(&cfg, tmp.path(), None).unwrap());
|
||||
let mem = Arc::from(memory::create_memory(&cfg, tmp.path()).unwrap());
|
||||
(mem, tmp)
|
||||
}
|
||||
|
||||
|
||||
@@ -245,7 +245,6 @@ fn build_remote_provider(config: &Config) -> anyhow::Result<ResolvedProvider> {
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
};
|
||||
let provider_box = providers::create_routed_provider_with_options(
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config.reliability,
|
||||
&config.model_routes,
|
||||
|
||||
@@ -58,7 +58,6 @@ pub(crate) struct ChannelRuntimeContext {
|
||||
pub(crate) conversation_histories: ConversationHistoryMap,
|
||||
pub(crate) provider_cache: ProviderCacheMap,
|
||||
pub(crate) route_overrides: RouteSelectionMap,
|
||||
pub(crate) api_key: Option<String>,
|
||||
pub(crate) api_url: Option<String>,
|
||||
pub(crate) reliability: Arc<crate::openhuman::config::ReliabilityConfig>,
|
||||
pub(crate) provider_runtime_options: crate::openhuman::providers::ProviderRuntimeOptions,
|
||||
@@ -330,7 +329,6 @@ mod tests {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: crate::openhuman::providers::ProviderRuntimeOptions::default(
|
||||
|
||||
@@ -178,7 +178,6 @@ pub(crate) async fn get_or_create_provider(
|
||||
};
|
||||
|
||||
let provider = providers::create_resilient_provider_with_options(
|
||||
ctx.api_key.as_deref(),
|
||||
api_url,
|
||||
&ctx.reliability,
|
||||
&ctx.provider_runtime_options,
|
||||
@@ -448,7 +447,6 @@ mod tests {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: ProviderCacheMap::default(),
|
||||
route_overrides: RouteSelectionMap::default(),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: crate::openhuman::providers::ProviderRuntimeOptions::default(
|
||||
|
||||
@@ -87,7 +87,6 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
reasoning_enabled: config.runtime.reasoning_enabled,
|
||||
};
|
||||
let provider: Arc<dyn Provider> = Arc::from(providers::create_intelligent_routing_provider(
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config,
|
||||
&provider_runtime_options,
|
||||
@@ -114,16 +113,7 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
&config.memory,
|
||||
Some(&config.storage.provider.config),
|
||||
&config.workspace_dir,
|
||||
config.api_key.as_deref(),
|
||||
)?);
|
||||
let (composio_key, composio_entity_id) = if config.composio.enabled {
|
||||
(
|
||||
config.composio.api_key.as_deref(),
|
||||
Some(config.composio.entity_id.as_str()),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
// Build system prompt from workspace identity files + skills
|
||||
let workspace = config.workspace_dir.clone();
|
||||
let tools_registry = Arc::new(tools::all_tools_with_runtime(
|
||||
@@ -131,13 +121,10 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
&security,
|
||||
runtime,
|
||||
Arc::clone(&mem),
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
&config.browser,
|
||||
&config.http_request,
|
||||
&workspace,
|
||||
&config.agents,
|
||||
config.api_key.as_deref(),
|
||||
&config,
|
||||
));
|
||||
|
||||
@@ -519,7 +506,6 @@ pub async fn start_channels(config: Config) -> Result<()> {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: config.api_key.clone(),
|
||||
api_url: config.api_url.clone(),
|
||||
reliability: Arc::new(config.reliability.clone()),
|
||||
provider_runtime_options,
|
||||
|
||||
@@ -77,7 +77,6 @@ fn compact_sender_history_keeps_recent_truncated_messages() {
|
||||
conversation_histories: Arc::new(Mutex::new(histories)),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
multimodal: crate::openhuman::config::MultimodalConfig::default(),
|
||||
|
||||
@@ -131,7 +131,6 @@ fn make_discord_ctx(
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: crate::openhuman::providers::ProviderRuntimeOptions::default(),
|
||||
|
||||
@@ -138,7 +138,6 @@ async fn process_channel_message_restores_per_sender_history_on_follow_ups() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
|
||||
@@ -46,7 +46,6 @@ async fn message_dispatch_processes_messages_in_parallel() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -120,7 +119,6 @@ async fn process_channel_message_cancels_scoped_typing_task() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -208,7 +206,6 @@ async fn dispatch_routes_through_agent_run_turn_bus_handler() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
|
||||
@@ -36,7 +36,6 @@ async fn process_channel_message_executes_tool_calls_instead_of_sending_raw_json
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -91,7 +90,6 @@ async fn process_channel_message_executes_tool_calls_with_alias_tags() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -155,7 +153,6 @@ async fn process_channel_message_handles_models_command_without_llm_call() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -246,7 +243,6 @@ async fn process_channel_message_uses_route_override_provider_and_model() {
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(provider_cache_seed)),
|
||||
route_overrides: Arc::new(Mutex::new(route_overrides)),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -295,7 +291,6 @@ async fn process_channel_message_respects_configured_max_tool_iterations_above_d
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
@@ -351,7 +346,6 @@ async fn process_channel_message_reports_configured_max_tool_iterations_limit()
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions::default(),
|
||||
|
||||
@@ -107,7 +107,6 @@ fn make_test_context(
|
||||
conversation_histories: Arc::new(Mutex::new(HashMap::new())),
|
||||
provider_cache: Arc::new(Mutex::new(HashMap::new())),
|
||||
route_overrides: Arc::new(Mutex::new(HashMap::new())),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
reliability: Arc::new(crate::openhuman::config::ReliabilityConfig::default()),
|
||||
provider_runtime_options: crate::openhuman::providers::ProviderRuntimeOptions::default(),
|
||||
|
||||
@@ -250,7 +250,7 @@ impl ComposioClient {
|
||||
///
|
||||
/// Composio is **always enabled** — there are no configuration flags
|
||||
/// gating it. The backend URL and auth token come from the shared
|
||||
/// core defaults (`config.api_url` / `config.api_key`) via
|
||||
/// core defaults (`config.api_url` plus the app-session JWT) via
|
||||
/// [`crate::openhuman::integrations::build_client`]. The only reason
|
||||
/// this returns `None` is that the user isn't signed in yet.
|
||||
pub fn build_composio_client(config: &crate::openhuman::config::Config) -> Option<ComposioClient> {
|
||||
@@ -267,23 +267,28 @@ mod tests {
|
||||
/// token — callers treat that as "skip silently" (user not signed in).
|
||||
#[test]
|
||||
fn build_composio_client_none_without_auth_token() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = Config::default();
|
||||
config.api_key = None;
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
assert!(build_composio_client(&config).is_none());
|
||||
}
|
||||
|
||||
/// With an auth token, we should get a live client wrapping the
|
||||
/// shared integration client. We scope `config_path` to a temp dir
|
||||
/// so the session-token lookup doesn't pick up a real dev profile
|
||||
/// off-disk — the test exercises the pure `config.api_key` fallback.
|
||||
#[test]
|
||||
fn build_composio_client_some_with_auth_token() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.api_key = Some("test-token".into());
|
||||
crate::openhuman::credentials::AuthService::from_config(&config)
|
||||
.store_provider_token(
|
||||
crate::openhuman::credentials::APP_SESSION_PROVIDER,
|
||||
crate::openhuman::credentials::DEFAULT_AUTH_PROFILE_NAME,
|
||||
"test-token",
|
||||
std::collections::HashMap::new(),
|
||||
true,
|
||||
)
|
||||
.expect("store test session token");
|
||||
let client =
|
||||
build_composio_client(&config).expect("client should build when api_key is set");
|
||||
build_composio_client(&config).expect("client should build when session is set");
|
||||
assert!(
|
||||
!client.inner().auth_token.is_empty(),
|
||||
"resolved auth token should not be empty"
|
||||
|
||||
@@ -770,7 +770,6 @@ mod tests {
|
||||
let mut c = Config::default();
|
||||
c.workspace_dir = tmp.path().join("workspace");
|
||||
c.config_path = tmp.path().join("config.toml");
|
||||
c.api_key = None; // ensure no token fallback
|
||||
c
|
||||
}
|
||||
|
||||
@@ -953,8 +952,16 @@ mod tests {
|
||||
let mut c = Config::default();
|
||||
c.workspace_dir = tmp.path().join("workspace");
|
||||
c.config_path = tmp.path().join("config.toml");
|
||||
c.api_key = Some("test-token".into());
|
||||
c.api_url = Some(base);
|
||||
crate::openhuman::credentials::AuthService::from_config(&c)
|
||||
.store_provider_token(
|
||||
crate::openhuman::credentials::APP_SESSION_PROVIDER,
|
||||
crate::openhuman::credentials::DEFAULT_AUTH_PROFILE_NAME,
|
||||
"test-token",
|
||||
std::collections::HashMap::new(),
|
||||
true,
|
||||
)
|
||||
.expect("store test session token");
|
||||
c
|
||||
}
|
||||
|
||||
|
||||
@@ -715,7 +715,6 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.api_key = None;
|
||||
let tools = all_composio_agent_tools(&config);
|
||||
assert!(tools.is_empty());
|
||||
}
|
||||
@@ -725,7 +724,15 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.config_path = tmp.path().join("config.toml");
|
||||
config.api_key = Some("sk-test".into());
|
||||
crate::openhuman::credentials::AuthService::from_config(&config)
|
||||
.store_provider_token(
|
||||
crate::openhuman::credentials::APP_SESSION_PROVIDER,
|
||||
crate::openhuman::credentials::DEFAULT_AUTH_PROFILE_NAME,
|
||||
"test-token",
|
||||
std::collections::HashMap::new(),
|
||||
true,
|
||||
)
|
||||
.expect("store test session token");
|
||||
let tools = all_composio_agent_tools(&config);
|
||||
assert_eq!(tools.len(), 5);
|
||||
}
|
||||
|
||||
+10
-12
@@ -25,18 +25,16 @@ pub use schema::{
|
||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
|
||||
AgentConfig, AuditConfig, AutocompleteConfig, AutonomyConfig, BrowserComputerUseConfig,
|
||||
BrowserConfig, ChannelsConfig, ClassificationRule, ComposioConfig, Config, ContextConfig,
|
||||
CostConfig, CronConfig, DelegateAgentConfig, DictationActivationMode, DictationConfig,
|
||||
DiscordConfig, DockerRuntimeConfig, EmbeddingRouteConfig, HeartbeatConfig, HttpRequestConfig,
|
||||
IMessageConfig, IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig,
|
||||
LocalAiConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, MultimodalConfig,
|
||||
ObservabilityConfig, OrchestratorConfig, PeripheralBoardConfig, PeripheralsConfig, ProxyConfig,
|
||||
ProxyScope, QueryClassificationConfig, ReflectionSource, ReliabilityConfig,
|
||||
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
|
||||
ScreenIntelligenceConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
|
||||
StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, UpdateConfig,
|
||||
VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig, DEFAULT_MODEL,
|
||||
MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
BrowserConfig, ChannelsConfig, ComposioConfig, Config, ContextConfig, CostConfig, CronConfig,
|
||||
DelegateAgentConfig, DictationActivationMode, DictationConfig, DiscordConfig,
|
||||
DockerRuntimeConfig, EmbeddingRouteConfig, HeartbeatConfig, HttpRequestConfig, IMessageConfig,
|
||||
IntegrationToggle, IntegrationsConfig, LarkConfig, LearningConfig, LocalAiConfig, MatrixConfig,
|
||||
MemoryConfig, ModelRouteConfig, MultimodalConfig, ObservabilityConfig, ProxyConfig, ProxyScope,
|
||||
ReflectionSource, ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend,
|
||||
SandboxConfig, SchedulerConfig, ScreenIntelligenceConfig, SecretsConfig, SecurityConfig,
|
||||
SlackConfig, StorageConfig, StorageProviderConfig, StorageProviderSection, StreamMode,
|
||||
TelegramConfig, UpdateConfig, VoiceActivationMode, VoiceServerConfig, WebSearchConfig,
|
||||
WebhookConfig, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CODING_V1, MODEL_REASONING_V1,
|
||||
};
|
||||
pub use schema::{
|
||||
clear_active_user, default_root_openhuman_dir, pre_login_user_dir, read_active_user_id,
|
||||
|
||||
@@ -150,7 +150,6 @@ pub fn snapshot_config_json(config: &Config) -> Result<serde_json::Value, String
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ModelSettingsPatch {
|
||||
pub api_key: Option<String>,
|
||||
pub api_url: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_temperature: Option<f64>,
|
||||
@@ -218,13 +217,6 @@ pub async fn apply_model_settings(
|
||||
config: &mut Config,
|
||||
update: ModelSettingsPatch,
|
||||
) -> Result<RpcOutcome<serde_json::Value>, String> {
|
||||
if let Some(api_key) = update.api_key {
|
||||
config.api_key = if api_key.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(api_key)
|
||||
};
|
||||
}
|
||||
if let Some(api_url) = update.api_url {
|
||||
config.api_url = if api_url.trim().is_empty() {
|
||||
None
|
||||
@@ -1030,33 +1022,31 @@ mod tests {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
let patch = ModelSettingsPatch {
|
||||
api_key: Some("sk-test".into()),
|
||||
api_url: Some("https://api.example.test".into()),
|
||||
default_model: Some("gpt-4o".into()),
|
||||
default_temperature: Some(0.25),
|
||||
};
|
||||
let outcome = apply_model_settings(&mut cfg, patch).await.expect("apply");
|
||||
assert_eq!(cfg.api_key.as_deref(), Some("sk-test"));
|
||||
assert_eq!(cfg.api_url.as_deref(), Some("https://api.example.test"));
|
||||
assert_eq!(cfg.default_model.as_deref(), Some("gpt-4o"));
|
||||
assert!((cfg.default_temperature - 0.25).abs() < f64::EPSILON);
|
||||
assert_eq!(outcome.value["config"]["api_key"], "sk-test");
|
||||
assert_eq!(
|
||||
outcome.value["config"]["api_url"],
|
||||
"https://api.example.test"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apply_model_settings_empty_strings_clear_optional_fields() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let mut cfg = tmp_config(&tmp);
|
||||
cfg.api_key = Some("prev".into());
|
||||
cfg.default_model = Some("prev-model".into());
|
||||
let patch = ModelSettingsPatch {
|
||||
api_key: Some(" ".into()),
|
||||
api_url: Some("".into()),
|
||||
default_model: Some("".into()),
|
||||
default_temperature: None,
|
||||
};
|
||||
let _ = apply_model_settings(&mut cfg, patch).await.expect("apply");
|
||||
assert!(cfg.api_key.is_none());
|
||||
assert!(cfg.api_url.is_none());
|
||||
assert!(cfg.default_model.is_none());
|
||||
}
|
||||
|
||||
@@ -11,9 +11,6 @@ pub struct DelegateAgentConfig {
|
||||
/// Optional system prompt for the sub-agent
|
||||
#[serde(default)]
|
||||
pub system_prompt: Option<String>,
|
||||
/// Optional API key override
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Temperature override
|
||||
#[serde(default)]
|
||||
pub temperature: Option<f64>,
|
||||
|
||||
@@ -231,8 +231,6 @@ pub struct LinqConfig {
|
||||
pub api_token: String,
|
||||
pub from_phone: String,
|
||||
#[serde(default)]
|
||||
pub signing_secret: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allowed_senders: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -326,38 +324,11 @@ pub enum SandboxBackend {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ResourceLimitsConfig {
|
||||
#[serde(default = "default_max_memory_mb")]
|
||||
pub max_memory_mb: u32,
|
||||
#[serde(default = "default_max_cpu_time_seconds")]
|
||||
pub max_cpu_time_seconds: u64,
|
||||
#[serde(default = "default_max_subprocesses")]
|
||||
pub max_subprocesses: u32,
|
||||
#[serde(default = "default_memory_monitoring_enabled")]
|
||||
pub memory_monitoring: bool,
|
||||
}
|
||||
|
||||
fn default_max_memory_mb() -> u32 {
|
||||
512
|
||||
}
|
||||
fn default_max_cpu_time_seconds() -> u64 {
|
||||
60
|
||||
}
|
||||
fn default_max_subprocesses() -> u32 {
|
||||
10
|
||||
}
|
||||
fn default_memory_monitoring_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
pub struct ResourceLimitsConfig {}
|
||||
|
||||
impl Default for ResourceLimitsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_memory_mb: default_max_memory_mb(),
|
||||
max_cpu_time_seconds: default_max_cpu_time_seconds(),
|
||||
max_subprocesses: default_max_subprocesses(),
|
||||
memory_monitoring: default_memory_monitoring_enabled(),
|
||||
}
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -369,8 +340,6 @@ pub struct AuditConfig {
|
||||
pub log_path: String,
|
||||
#[serde(default = "default_audit_max_size_mb")]
|
||||
pub max_size_mb: u32,
|
||||
#[serde(default)]
|
||||
pub sign_events: bool,
|
||||
}
|
||||
|
||||
fn default_audit_enabled() -> bool {
|
||||
@@ -389,7 +358,6 @@ impl Default for AuditConfig {
|
||||
enabled: default_audit_enabled(),
|
||||
log_path: default_audit_log_path(),
|
||||
max_size_mb: default_audit_max_size_mb(),
|
||||
sign_events: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -561,11 +529,6 @@ mod tests {
|
||||
assert!(sec.audit.enabled);
|
||||
assert_eq!(sec.audit.log_path, "audit.log");
|
||||
assert_eq!(sec.audit.max_size_mb, 100);
|
||||
assert!(!sec.audit.sign_events);
|
||||
assert_eq!(sec.resources.max_memory_mb, 512);
|
||||
assert_eq!(sec.resources.max_cpu_time_seconds, 60);
|
||||
assert_eq!(sec.resources.max_subprocesses, 10);
|
||||
assert!(sec.resources.memory_monitoring);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -33,27 +33,6 @@ pub struct ContextConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub autocompact_enabled: bool,
|
||||
|
||||
/// Soft compaction trigger as a 0-100 percentage of the model's
|
||||
/// context window. When utilization crosses this, the pipeline runs
|
||||
/// microcompact and (if that doesn't free enough) summarization.
|
||||
/// Defaults to 90 to match the long-standing hardcoded threshold.
|
||||
#[serde(default = "default_compaction_trigger_pct")]
|
||||
pub compaction_trigger_pct: u8,
|
||||
|
||||
/// Hard limit as a 0-100 percentage. Above this and with the
|
||||
/// compaction circuit breaker tripped, the guard returns
|
||||
/// `ContextExhausted` so the agent aborts the turn rather than
|
||||
/// sending an oversized request. Defaults to 95.
|
||||
#[serde(default = "default_hard_limit_pct")]
|
||||
pub hard_limit_pct: u8,
|
||||
|
||||
/// Token budget reserved for the model's output. Subtracted from the
|
||||
/// available budget when deciding how aggressively to reduce the
|
||||
/// prompt. Defaults to 10_000 — large enough for a comfortable
|
||||
/// agentic response without eating too much of the window.
|
||||
#[serde(default = "default_reserve_output_tokens")]
|
||||
pub reserve_output_tokens: u64,
|
||||
|
||||
/// How many of the most-recent `ToolResults` envelopes microcompact
|
||||
/// leaves untouched when it runs. Older envelopes are cleared first.
|
||||
#[serde(default = "default_microcompact_keep_recent")]
|
||||
@@ -125,18 +104,6 @@ fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_compaction_trigger_pct() -> u8 {
|
||||
90
|
||||
}
|
||||
|
||||
fn default_hard_limit_pct() -> u8 {
|
||||
95
|
||||
}
|
||||
|
||||
fn default_reserve_output_tokens() -> u64 {
|
||||
10_000
|
||||
}
|
||||
|
||||
fn default_microcompact_keep_recent() -> usize {
|
||||
crate::openhuman::context::DEFAULT_KEEP_RECENT_TOOL_RESULTS
|
||||
}
|
||||
@@ -163,9 +130,6 @@ impl Default for ContextConfig {
|
||||
enabled: default_enabled(),
|
||||
microcompact_enabled: default_true(),
|
||||
autocompact_enabled: default_true(),
|
||||
compaction_trigger_pct: default_compaction_trigger_pct(),
|
||||
hard_limit_pct: default_hard_limit_pct(),
|
||||
reserve_output_tokens: default_reserve_output_tokens(),
|
||||
microcompact_keep_recent: default_microcompact_keep_recent(),
|
||||
tool_result_budget_bytes: default_tool_result_budget_bytes(),
|
||||
summarizer_payload_threshold_tokens: default_summarizer_payload_threshold_tokens(),
|
||||
|
||||
@@ -18,9 +18,6 @@ pub struct HeartbeatConfig {
|
||||
/// Maximum token budget for the situation report (default 40k).
|
||||
#[serde(default = "default_context_budget")]
|
||||
pub context_budget_tokens: u32,
|
||||
/// Override model for escalation (default: use config.default_model).
|
||||
#[serde(default)]
|
||||
pub escalation_model: Option<String>,
|
||||
}
|
||||
|
||||
fn default_context_budget() -> u32 {
|
||||
@@ -34,7 +31,6 @@ impl Default for HeartbeatConfig {
|
||||
interval_minutes: 5,
|
||||
inference_enabled: true,
|
||||
context_budget_tokens: default_context_budget(),
|
||||
escalation_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,6 @@ pub struct CostConfig {
|
||||
#[serde(default = "default_warn_percent")]
|
||||
pub warn_at_percent: u8,
|
||||
|
||||
/// Allow requests to exceed budget with --override flag (default: false)
|
||||
#[serde(default)]
|
||||
pub allow_override: bool,
|
||||
|
||||
/// Per-model pricing (USD per 1M tokens)
|
||||
#[serde(default)]
|
||||
pub prices: HashMap<String, ModelPricing>,
|
||||
@@ -64,7 +60,6 @@ impl Default for CostConfig {
|
||||
daily_limit_usd: default_daily_limit(),
|
||||
monthly_limit_usd: default_monthly_limit(),
|
||||
warn_at_percent: default_warn_percent(),
|
||||
allow_override: false,
|
||||
prices: get_default_pricing(),
|
||||
}
|
||||
}
|
||||
@@ -101,46 +96,6 @@ fn get_default_pricing() -> HashMap<String, ModelPricing> {
|
||||
prices
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub struct PeripheralsConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub boards: Vec<PeripheralBoardConfig>,
|
||||
#[serde(default)]
|
||||
pub datasheet_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct PeripheralBoardConfig {
|
||||
pub board: String,
|
||||
#[serde(default = "default_peripheral_transport")]
|
||||
pub transport: String,
|
||||
#[serde(default)]
|
||||
pub path: Option<String>,
|
||||
#[serde(default = "default_peripheral_baud")]
|
||||
pub baud: u32,
|
||||
}
|
||||
|
||||
fn default_peripheral_transport() -> String {
|
||||
"serial".into()
|
||||
}
|
||||
|
||||
fn default_peripheral_baud() -> u32 {
|
||||
115_200
|
||||
}
|
||||
|
||||
impl Default for PeripheralBoardConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
board: String::new(),
|
||||
transport: default_peripheral_transport(),
|
||||
path: None,
|
||||
baud: default_peripheral_baud(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -152,7 +107,6 @@ mod tests {
|
||||
assert_eq!(c.daily_limit_usd, 10.0);
|
||||
assert_eq!(c.monthly_limit_usd, 100.0);
|
||||
assert_eq!(c.warn_at_percent, 80);
|
||||
assert!(!c.allow_override);
|
||||
assert!(!c.prices.is_empty());
|
||||
}
|
||||
|
||||
@@ -178,14 +132,12 @@ mod tests {
|
||||
daily_limit_usd = 50.0
|
||||
monthly_limit_usd = 500.0
|
||||
warn_at_percent = 90
|
||||
allow_override = true
|
||||
"#;
|
||||
let c: CostConfig = toml::from_str(toml).unwrap();
|
||||
assert!(c.enabled);
|
||||
assert_eq!(c.daily_limit_usd, 50.0);
|
||||
assert_eq!(c.monthly_limit_usd, 500.0);
|
||||
assert_eq!(c.warn_at_percent, 90);
|
||||
assert!(c.allow_override);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -194,55 +146,4 @@ mod tests {
|
||||
assert_eq!(p.input, 0.0);
|
||||
assert_eq!(p.output, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripherals_config_defaults() {
|
||||
let p = PeripheralsConfig::default();
|
||||
assert!(!p.enabled);
|
||||
assert!(p.boards.is_empty());
|
||||
assert!(p.datasheet_dir.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_board_config_defaults() {
|
||||
let b = PeripheralBoardConfig::default();
|
||||
assert_eq!(b.transport, "serial");
|
||||
assert_eq!(b.baud, 115_200);
|
||||
assert!(b.board.is_empty());
|
||||
assert!(b.path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripheral_board_config_toml() {
|
||||
let toml = r#"
|
||||
board = "esp32"
|
||||
transport = "usb"
|
||||
path = "/dev/ttyUSB0"
|
||||
baud = 9600
|
||||
"#;
|
||||
let b: PeripheralBoardConfig = toml::from_str(toml).unwrap();
|
||||
assert_eq!(b.board, "esp32");
|
||||
assert_eq!(b.transport, "usb");
|
||||
assert_eq!(b.path.as_deref(), Some("/dev/ttyUSB0"));
|
||||
assert_eq!(b.baud, 9600);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peripherals_config_serde_roundtrip() {
|
||||
let p = PeripheralsConfig {
|
||||
enabled: true,
|
||||
boards: vec![PeripheralBoardConfig {
|
||||
board: "arduino".into(),
|
||||
transport: "serial".into(),
|
||||
path: Some("/dev/cu.usbmodem".into()),
|
||||
baud: 115_200,
|
||||
}],
|
||||
datasheet_dir: Some("/tmp/sheets".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: PeripheralsConfig = serde_json::from_str(&json).unwrap();
|
||||
assert!(back.enabled);
|
||||
assert_eq!(back.boards.len(), 1);
|
||||
assert_eq!(back.boards[0].board, "arduino");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +35,6 @@ pub struct LearningConfig {
|
||||
#[serde(default = "default_true")]
|
||||
pub tool_tracking_enabled: bool,
|
||||
|
||||
/// Enable autonomous skill creation from experience. Default: false (Phase 5).
|
||||
#[serde(default)]
|
||||
pub skill_creation_enabled: bool,
|
||||
|
||||
/// Which LLM to use for reflection. Default: local (Ollama).
|
||||
#[serde(default)]
|
||||
pub reflection_source: ReflectionSource,
|
||||
@@ -71,7 +67,6 @@ impl Default for LearningConfig {
|
||||
reflection_enabled: default_true(),
|
||||
user_profile_enabled: default_true(),
|
||||
tool_tracking_enabled: default_true(),
|
||||
skill_creation_enabled: false,
|
||||
reflection_source: ReflectionSource::default(),
|
||||
max_reflections_per_session: default_max_reflections(),
|
||||
min_turn_complexity: default_min_turn_complexity(),
|
||||
|
||||
@@ -503,39 +503,6 @@ impl Config {
|
||||
})?;
|
||||
config.config_path = config_path.clone();
|
||||
config.workspace_dir = workspace_dir;
|
||||
let store = crate::openhuman::security::SecretStore::new(
|
||||
&openhuman_dir,
|
||||
config.secrets.encrypt,
|
||||
);
|
||||
decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.composio.api_key,
|
||||
"config.composio.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.browser.computer_use.api_key,
|
||||
"config.browser.computer_use.api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.web_search.brave_api_key,
|
||||
"config.web_search.brave_api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.web_search.parallel_api_key,
|
||||
"config.web_search.parallel_api_key",
|
||||
)?;
|
||||
decrypt_optional_secret(
|
||||
&store,
|
||||
&mut config.storage.provider.config.db_url,
|
||||
"config.storage.provider.config.db_url",
|
||||
)?;
|
||||
for agent in config.agents.values_mut() {
|
||||
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||||
}
|
||||
migrate_legacy_autocomplete_disabled_apps(&mut config);
|
||||
config.apply_env_overrides();
|
||||
|
||||
@@ -609,11 +576,6 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
if let Ok(key) = std::env::var("OPENHUMAN_API_KEY").or_else(|_| std::env::var("API_KEY")) {
|
||||
if !key.is_empty() {
|
||||
self.api_key = Some(key);
|
||||
}
|
||||
}
|
||||
if let Ok(model) = std::env::var("OPENHUMAN_MODEL").or_else(|_| std::env::var("MODEL")) {
|
||||
if !model.is_empty() {
|
||||
self.default_model = Some(model);
|
||||
@@ -649,44 +611,14 @@ impl Config {
|
||||
|
||||
// `OPENHUMAN_WEB_SEARCH_ENABLED` is intentionally ignored —
|
||||
// web search is unconditionally registered in the tool set.
|
||||
// Only the provider / API-key / budget knobs remain
|
||||
// environment-configurable. Emit a one-shot deprecation warning
|
||||
// if the caller still sets it so stale scripts surface clearly.
|
||||
// Only the result/timeout budget knobs remain environment-configurable.
|
||||
if std::env::var_os("OPENHUMAN_WEB_SEARCH_ENABLED").is_some() {
|
||||
log::warn!(
|
||||
"[config] OPENHUMAN_WEB_SEARCH_ENABLED is deprecated and ignored — \
|
||||
web search is always registered; use OPENHUMAN_WEB_SEARCH_PROVIDER / \
|
||||
API-key / budget env vars instead."
|
||||
web search is always registered; provider/API-key overrides were removed."
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(provider) = std::env::var("OPENHUMAN_WEB_SEARCH_PROVIDER")
|
||||
.or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
|
||||
{
|
||||
let provider = provider.trim();
|
||||
if !provider.is_empty() {
|
||||
self.web_search.provider = provider.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(api_key) =
|
||||
std::env::var("OPENHUMAN_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
|
||||
{
|
||||
let api_key = api_key.trim();
|
||||
if !api_key.is_empty() {
|
||||
self.web_search.brave_api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(api_key) = std::env::var("OPENHUMAN_PARALLEL_API_KEY")
|
||||
.or_else(|_| std::env::var("PARALLEL_API_KEY"))
|
||||
{
|
||||
let api_key = api_key.trim();
|
||||
if !api_key.is_empty() {
|
||||
self.web_search.parallel_api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(max_results) = std::env::var("OPENHUMAN_WEB_SEARCH_MAX_RESULTS")
|
||||
.or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
|
||||
{
|
||||
@@ -707,28 +639,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(provider) = std::env::var("OPENHUMAN_STORAGE_PROVIDER") {
|
||||
let provider = provider.trim();
|
||||
if !provider.is_empty() {
|
||||
self.storage.provider.config.provider = provider.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(db_url) = std::env::var("OPENHUMAN_STORAGE_DB_URL") {
|
||||
let db_url = db_url.trim();
|
||||
if !db_url.is_empty() {
|
||||
self.storage.provider.config.db_url = Some(db_url.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(timeout_secs) = std::env::var("OPENHUMAN_STORAGE_CONNECT_TIMEOUT_SECS") {
|
||||
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
|
||||
if timeout_secs > 0 {
|
||||
self.storage.provider.config.connect_timeout_secs = Some(timeout_secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let explicit_proxy_enabled = std::env::var("OPENHUMAN_PROXY_ENABLED")
|
||||
.ok()
|
||||
.as_deref()
|
||||
@@ -888,14 +798,6 @@ impl Config {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Ok(flag) = std::env::var("OPENHUMAN_LEARNING_SKILL_CREATION_ENABLED") {
|
||||
let normalized = flag.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
"1" | "true" | "yes" | "on" => self.learning.skill_creation_enabled = true,
|
||||
"0" | "false" | "no" | "off" => self.learning.skill_creation_enabled = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Ok(source) = std::env::var("OPENHUMAN_LEARNING_REFLECTION_SOURCE") {
|
||||
let normalized = source.trim().to_ascii_lowercase();
|
||||
match normalized.as_str() {
|
||||
@@ -1022,62 +924,6 @@ impl Config {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Parse both percentage env vars into temporaries so we can
|
||||
// enforce the `compaction < hard_limit` invariant before
|
||||
// touching the live config. Each slot is independently
|
||||
// validated (1..=100, rejecting 0 so we never arm the guard at
|
||||
// an always-true trigger) and then cross-validated as a pair.
|
||||
// On any failure we leave the existing values intact and emit a
|
||||
// warning naming the offending env var + value.
|
||||
let compaction_raw = std::env::var("OPENHUMAN_CONTEXT_COMPACTION_TRIGGER_PCT").ok();
|
||||
let hard_limit_raw = std::env::var("OPENHUMAN_CONTEXT_HARD_LIMIT_PCT").ok();
|
||||
|
||||
let parse_pct = |name: &str, raw: &str| -> Option<u8> {
|
||||
match raw.trim().parse::<u8>() {
|
||||
Ok(pct) if (1..=100).contains(&pct) => Some(pct),
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
env = %name,
|
||||
value = %raw,
|
||||
"[context:config] invalid percentage — must be integer in 1..=100; ignoring"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let new_compaction = compaction_raw
|
||||
.as_deref()
|
||||
.and_then(|v| parse_pct("OPENHUMAN_CONTEXT_COMPACTION_TRIGGER_PCT", v));
|
||||
let new_hard_limit = hard_limit_raw
|
||||
.as_deref()
|
||||
.and_then(|v| parse_pct("OPENHUMAN_CONTEXT_HARD_LIMIT_PCT", v));
|
||||
|
||||
// Effective pair after applying whichever overrides parsed
|
||||
// cleanly, falling back to the current live values for any
|
||||
// unset side.
|
||||
let effective_compaction = new_compaction.unwrap_or(self.context.compaction_trigger_pct);
|
||||
let effective_hard_limit = new_hard_limit.unwrap_or(self.context.hard_limit_pct);
|
||||
|
||||
if effective_compaction < effective_hard_limit {
|
||||
if let Some(pct) = new_compaction {
|
||||
self.context.compaction_trigger_pct = pct;
|
||||
}
|
||||
if let Some(pct) = new_hard_limit {
|
||||
self.context.hard_limit_pct = pct;
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
compaction_trigger_pct = effective_compaction,
|
||||
hard_limit_pct = effective_hard_limit,
|
||||
"[context:config] refusing env overrides — compaction_trigger_pct must be strictly less than hard_limit_pct; leaving existing values unchanged"
|
||||
);
|
||||
}
|
||||
if let Ok(val) = std::env::var("OPENHUMAN_CONTEXT_RESERVE_OUTPUT_TOKENS") {
|
||||
if let Ok(n) = val.trim().parse::<u64>() {
|
||||
self.context.reserve_output_tokens = n;
|
||||
}
|
||||
}
|
||||
if let Ok(val) = std::env::var("OPENHUMAN_CONTEXT_TOOL_RESULT_BUDGET_BYTES") {
|
||||
if let Ok(n) = val.trim().parse::<usize>() {
|
||||
self.context.tool_result_budget_bytes = n;
|
||||
@@ -1124,43 +970,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub async fn save(&self) -> Result<()> {
|
||||
let mut config_to_save = self.clone();
|
||||
let openhuman_dir = self
|
||||
.config_path
|
||||
.parent()
|
||||
.context("Config path must have a parent directory")?;
|
||||
let store =
|
||||
crate::openhuman::security::SecretStore::new(openhuman_dir, self.secrets.encrypt);
|
||||
|
||||
encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.composio.api_key,
|
||||
"config.composio.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.browser.computer_use.api_key,
|
||||
"config.browser.computer_use.api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.web_search.brave_api_key,
|
||||
"config.web_search.brave_api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.web_search.parallel_api_key,
|
||||
"config.web_search.parallel_api_key",
|
||||
)?;
|
||||
encrypt_optional_secret(
|
||||
&store,
|
||||
&mut config_to_save.storage.provider.config.db_url,
|
||||
"config.storage.provider.config.db_url",
|
||||
)?;
|
||||
for agent in config_to_save.agents.values_mut() {
|
||||
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
|
||||
}
|
||||
let config_to_save = self.clone();
|
||||
|
||||
let toml_str =
|
||||
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
|
||||
@@ -1349,38 +1159,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_picks_up_api_key() {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
clear_env(&["OPENHUMAN_API_KEY", "API_KEY", "OPENHUMAN_MODEL", "MODEL"]);
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_API_KEY", "sk-test");
|
||||
}
|
||||
let mut cfg = Config::default();
|
||||
cfg.apply_env_overrides();
|
||||
assert_eq!(cfg.api_key.as_deref(), Some("sk-test"));
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_ignores_empty_api_key() {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
clear_env(&["OPENHUMAN_API_KEY", "API_KEY"]);
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_API_KEY", "");
|
||||
}
|
||||
let mut cfg = Config::default();
|
||||
cfg.api_key = Some("prior".into());
|
||||
cfg.apply_env_overrides();
|
||||
// Empty env var must not overwrite existing value.
|
||||
assert_eq!(cfg.api_key.as_deref(), Some("prior"));
|
||||
unsafe {
|
||||
std::env::remove_var("OPENHUMAN_API_KEY");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_picks_up_model() {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
@@ -1457,30 +1235,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_web_search_provider_and_api_keys() {
|
||||
fn apply_env_overrides_web_search_limits_only() {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
clear_env(&[
|
||||
"OPENHUMAN_WEB_SEARCH_PROVIDER",
|
||||
"WEB_SEARCH_PROVIDER",
|
||||
"OPENHUMAN_BRAVE_API_KEY",
|
||||
"BRAVE_API_KEY",
|
||||
"OPENHUMAN_PARALLEL_API_KEY",
|
||||
"PARALLEL_API_KEY",
|
||||
"OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
|
||||
"WEB_SEARCH_MAX_RESULTS",
|
||||
"OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
|
||||
"WEB_SEARCH_TIMEOUT_SECS",
|
||||
]);
|
||||
let mut cfg = Config::default();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_WEB_SEARCH_PROVIDER", "brave");
|
||||
std::env::set_var("OPENHUMAN_BRAVE_API_KEY", "bk-1");
|
||||
std::env::set_var("OPENHUMAN_PARALLEL_API_KEY", "pk-1");
|
||||
std::env::set_var("OPENHUMAN_WEB_SEARCH_MAX_RESULTS", "5");
|
||||
std::env::set_var("OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS", "20");
|
||||
}
|
||||
cfg.apply_env_overrides();
|
||||
assert_eq!(cfg.web_search.provider, "brave");
|
||||
assert_eq!(cfg.web_search.brave_api_key.as_deref(), Some("bk-1"));
|
||||
assert_eq!(cfg.web_search.parallel_api_key.as_deref(), Some("pk-1"));
|
||||
assert_eq!(cfg.web_search.max_results, 5);
|
||||
assert_eq!(cfg.web_search.timeout_secs, 20);
|
||||
clear_env(&[
|
||||
"OPENHUMAN_WEB_SEARCH_PROVIDER",
|
||||
"OPENHUMAN_BRAVE_API_KEY",
|
||||
"OPENHUMAN_PARALLEL_API_KEY",
|
||||
"OPENHUMAN_WEB_SEARCH_MAX_RESULTS",
|
||||
"OPENHUMAN_WEB_SEARCH_TIMEOUT_SECS",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1524,21 +1297,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_env_overrides_storage_provider_and_db_url() {
|
||||
fn apply_env_overrides_picks_up_sentry_dsn() {
|
||||
let _g = ENV_LOCK.lock().unwrap();
|
||||
clear_env(&["OPENHUMAN_STORAGE_PROVIDER", "OPENHUMAN_STORAGE_DB_URL"]);
|
||||
clear_env(&["OPENHUMAN_SENTRY_DSN"]);
|
||||
let mut cfg = Config::default();
|
||||
unsafe {
|
||||
std::env::set_var("OPENHUMAN_STORAGE_PROVIDER", "postgres");
|
||||
std::env::set_var("OPENHUMAN_STORAGE_DB_URL", "postgres://host/db");
|
||||
std::env::set_var("OPENHUMAN_SENTRY_DSN", "https://token@sentry.io/1");
|
||||
}
|
||||
cfg.apply_env_overrides();
|
||||
assert_eq!(cfg.storage.provider.config.provider, "postgres");
|
||||
assert_eq!(
|
||||
cfg.storage.provider.config.db_url.as_deref(),
|
||||
Some("postgres://host/db")
|
||||
cfg.observability.sentry_dsn.as_deref(),
|
||||
Some("https://token@sentry.io/1")
|
||||
);
|
||||
clear_env(&["OPENHUMAN_STORAGE_PROVIDER", "OPENHUMAN_STORAGE_DB_URL"]);
|
||||
clear_env(&["OPENHUMAN_SENTRY_DSN"]);
|
||||
}
|
||||
|
||||
// ── resolve_config_dir_for_workspace ───────────────────────────
|
||||
|
||||
@@ -39,14 +39,8 @@ pub struct LocalAiConfig {
|
||||
pub preload_tts_voice: bool,
|
||||
#[serde(default = "default_download_url")]
|
||||
pub download_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub checksum_sha256: Option<String>,
|
||||
#[serde(default = "default_artifact_name")]
|
||||
pub artifact_name: String,
|
||||
#[serde(default = "default_autosummary_debounce_ms")]
|
||||
pub autosummary_debounce_ms: u64,
|
||||
#[serde(default = "default_context_compaction_threshold_tokens")]
|
||||
pub context_compaction_threshold_tokens: usize,
|
||||
#[serde(default = "default_max_suggestions")]
|
||||
pub max_suggestions: usize,
|
||||
#[serde(default)]
|
||||
@@ -147,18 +141,10 @@ fn default_download_url() -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn default_artifact_name() -> String {
|
||||
"ollama-managed".to_string()
|
||||
}
|
||||
|
||||
fn default_autosummary_debounce_ms() -> u64 {
|
||||
2500
|
||||
}
|
||||
|
||||
fn default_context_compaction_threshold_tokens() -> usize {
|
||||
100_000
|
||||
}
|
||||
|
||||
fn default_max_suggestions() -> usize {
|
||||
5
|
||||
}
|
||||
@@ -191,10 +177,7 @@ impl Default for LocalAiConfig {
|
||||
preload_stt_model: default_preload_stt_model(),
|
||||
preload_tts_voice: default_preload_tts_voice(),
|
||||
download_url: default_download_url(),
|
||||
checksum_sha256: None,
|
||||
artifact_name: default_artifact_name(),
|
||||
autosummary_debounce_ms: default_autosummary_debounce_ms(),
|
||||
context_compaction_threshold_tokens: default_context_compaction_threshold_tokens(),
|
||||
max_suggestions: default_max_suggestions(),
|
||||
selected_tier: None,
|
||||
opt_in_confirmed: false,
|
||||
|
||||
@@ -21,7 +21,6 @@ pub use load::{
|
||||
mod local_ai;
|
||||
mod node;
|
||||
mod observability;
|
||||
mod orchestrator;
|
||||
mod proxy;
|
||||
mod routes;
|
||||
mod runtime;
|
||||
@@ -42,20 +41,17 @@ pub use channels::{
|
||||
pub use context::ContextConfig;
|
||||
pub use dictation::{DictationActivationMode, DictationConfig};
|
||||
pub use heartbeat_cron::{CronConfig, HeartbeatConfig};
|
||||
pub use identity_cost::{CostConfig, ModelPricing, PeripheralBoardConfig, PeripheralsConfig};
|
||||
pub use identity_cost::{CostConfig, ModelPricing};
|
||||
pub use learning::{LearningConfig, ReflectionSource};
|
||||
pub use local_ai::LocalAiConfig;
|
||||
pub use node::NodeConfig;
|
||||
pub use observability::ObservabilityConfig;
|
||||
pub use orchestrator::OrchestratorConfig;
|
||||
pub use proxy::{
|
||||
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
|
||||
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
|
||||
ProxyConfig, ProxyScope,
|
||||
};
|
||||
pub use routes::{
|
||||
ClassificationRule, EmbeddingRouteConfig, ModelRouteConfig, QueryClassificationConfig,
|
||||
};
|
||||
pub use routes::{EmbeddingRouteConfig, ModelRouteConfig};
|
||||
pub use runtime::{DockerRuntimeConfig, ReliabilityConfig, RuntimeConfig, SchedulerConfig};
|
||||
pub use storage_memory::{
|
||||
MemoryConfig, StorageConfig, StorageProviderConfig, StorageProviderSection,
|
||||
|
||||
@@ -5,17 +5,6 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ObservabilityConfig {
|
||||
/// "none" | "log" | "prometheus" | "otel"
|
||||
pub backend: String,
|
||||
|
||||
/// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel".
|
||||
#[serde(default)]
|
||||
pub otel_endpoint: Option<String>,
|
||||
|
||||
/// Service name reported to the OTel collector. Defaults to "openhuman".
|
||||
#[serde(default)]
|
||||
pub otel_service_name: Option<String>,
|
||||
|
||||
/// Sentry DSN for error reporting. Overridden by `OPENHUMAN_SENTRY_DSN` env var.
|
||||
#[serde(default)]
|
||||
pub sentry_dsn: Option<String>,
|
||||
@@ -33,9 +22,6 @@ fn default_analytics_enabled() -> bool {
|
||||
impl Default for ObservabilityConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "none".into(),
|
||||
otel_endpoint: None,
|
||||
otel_service_name: None,
|
||||
sentry_dsn: None,
|
||||
analytics_enabled: true,
|
||||
}
|
||||
@@ -48,11 +34,8 @@ mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn default_disables_backend_and_enables_analytics() {
|
||||
fn default_enables_analytics() {
|
||||
let cfg = ObservabilityConfig::default();
|
||||
assert_eq!(cfg.backend, "none");
|
||||
assert!(cfg.otel_endpoint.is_none());
|
||||
assert!(cfg.otel_service_name.is_none());
|
||||
assert!(cfg.sentry_dsn.is_none());
|
||||
assert!(cfg.analytics_enabled);
|
||||
}
|
||||
@@ -64,12 +47,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn deserialize_missing_optional_fields_uses_defaults() {
|
||||
let cfg: ObservabilityConfig = serde_json::from_value(json!({
|
||||
"backend": "log"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(cfg.backend, "log");
|
||||
assert!(cfg.otel_endpoint.is_none());
|
||||
let cfg: ObservabilityConfig = serde_json::from_value(json!({})).unwrap();
|
||||
assert!(cfg.analytics_enabled, "analytics default must be true");
|
||||
}
|
||||
|
||||
@@ -86,17 +64,11 @@ mod tests {
|
||||
#[test]
|
||||
fn round_trip_preserves_all_fields() {
|
||||
let original = ObservabilityConfig {
|
||||
backend: "otel".into(),
|
||||
otel_endpoint: Some("http://localhost:4318".into()),
|
||||
otel_service_name: Some("openhuman-test".into()),
|
||||
sentry_dsn: Some("https://token@sentry.io/1".into()),
|
||||
analytics_enabled: false,
|
||||
};
|
||||
let s = serde_json::to_string(&original).unwrap();
|
||||
let back: ObservabilityConfig = serde_json::from_str(&s).unwrap();
|
||||
assert_eq!(back.backend, "otel");
|
||||
assert_eq!(back.otel_endpoint.as_deref(), Some("http://localhost:4318"));
|
||||
assert_eq!(back.otel_service_name.as_deref(), Some("openhuman-test"));
|
||||
assert_eq!(
|
||||
back.sentry_dsn.as_deref(),
|
||||
Some("https://token@sentry.io/1")
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
//! Orchestrator / multi-agent harness configuration.
|
||||
//!
|
||||
//! The fields here gate the orthogonal sub-agent features that run
|
||||
//! alongside the main agent's tool loop. There is no "DAG planner"
|
||||
//! flow any more — delegation is always done through the
|
||||
//! `spawn_subagent` tool, which hands off to an
|
||||
//! [`crate::openhuman::agent::harness::definition::AgentDefinition`]
|
||||
//! looked up in the global registry.
|
||||
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Configuration for the multi-agent orchestrator harness.
|
||||
///
|
||||
/// None of these fields enable or disable the multi-agent harness as a
|
||||
/// whole — sub-agent delegation through `spawn_subagent` is always
|
||||
/// available to the main agent. They only toggle the orthogonal
|
||||
/// features listed below.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct OrchestratorConfig {
|
||||
/// Enable the Archivist background daemon (post-session nudge loop).
|
||||
#[serde(default = "default_true")]
|
||||
pub archivist_enabled: bool,
|
||||
|
||||
/// Enable FTS5 episodic recall tables in SQLite memory.
|
||||
#[serde(default = "default_true")]
|
||||
pub fts5_enabled: bool,
|
||||
|
||||
/// Enable self-healing (ToolMaker auto-polyfill on "command not found").
|
||||
#[serde(default = "default_true")]
|
||||
pub self_healing_enabled: bool,
|
||||
|
||||
/// Allow `spawn_subagent { mode: "fork", … }` calls. Fork mode replays
|
||||
/// the parent's exact rendered prompt + tool schemas + message prefix
|
||||
/// so the inference backend's automatic prefix caching kicks in.
|
||||
/// Defaults to true; flip to false to force every sub-agent into
|
||||
/// typed mode (e.g. on backends that don't benefit from prefix
|
||||
/// caching, or while debugging).
|
||||
#[serde(default = "default_true")]
|
||||
pub fork_mode_enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for OrchestratorConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
archivist_enabled: default_true(),
|
||||
fts5_enabled: default_true(),
|
||||
self_healing_enabled: default_true(),
|
||||
fork_mode_enabled: default_true(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ use serde::{Deserialize, Serialize};
|
||||
pub struct ModelRouteConfig {
|
||||
pub hint: String,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
@@ -18,29 +16,4 @@ pub struct EmbeddingRouteConfig {
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub dimensions: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub struct QueryClassificationConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub rules: Vec<ClassificationRule>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
|
||||
pub struct ClassificationRule {
|
||||
pub hint: String,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub patterns: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub min_length: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub max_length: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub priority: i32,
|
||||
}
|
||||
|
||||
@@ -90,8 +90,6 @@ pub struct ReliabilityConfig {
|
||||
#[serde(default)]
|
||||
pub fallback_providers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub api_keys: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub model_fallbacks: HashMap<String, Vec<String>>,
|
||||
#[serde(default = "default_channel_backoff_secs")]
|
||||
pub channel_initial_backoff_secs: u64,
|
||||
@@ -133,7 +131,6 @@ impl Default for ReliabilityConfig {
|
||||
provider_retries: default_provider_retries(),
|
||||
provider_backoff_ms: default_provider_backoff_ms(),
|
||||
fallback_providers: Vec::new(),
|
||||
api_keys: Vec::new(),
|
||||
model_fallbacks: HashMap::new(),
|
||||
channel_initial_backoff_secs: default_channel_backoff_secs(),
|
||||
channel_max_backoff_secs: default_channel_backoff_max_secs(),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Storage provider and memory configuration.
|
||||
|
||||
use super::defaults;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -20,41 +19,12 @@ pub struct StorageProviderSection {
|
||||
pub struct StorageProviderConfig {
|
||||
#[serde(default)]
|
||||
pub provider: String,
|
||||
|
||||
#[serde(
|
||||
default,
|
||||
alias = "dbURL",
|
||||
alias = "database_url",
|
||||
alias = "databaseUrl"
|
||||
)]
|
||||
pub db_url: Option<String>,
|
||||
|
||||
#[serde(default = "default_storage_schema")]
|
||||
pub schema: String,
|
||||
|
||||
#[serde(default = "default_storage_table")]
|
||||
pub table: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub connect_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_storage_schema() -> String {
|
||||
"public".into()
|
||||
}
|
||||
|
||||
fn default_storage_table() -> String {
|
||||
"memories".into()
|
||||
}
|
||||
|
||||
impl Default for StorageProviderConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: String::new(),
|
||||
db_url: None,
|
||||
schema: default_storage_schema(),
|
||||
table: default_storage_table(),
|
||||
connect_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,116 +34,43 @@ impl Default for StorageProviderConfig {
|
||||
pub struct MemoryConfig {
|
||||
pub backend: String,
|
||||
pub auto_save: bool,
|
||||
#[serde(default = "default_hygiene_enabled")]
|
||||
pub hygiene_enabled: bool,
|
||||
#[serde(default = "default_archive_after_days")]
|
||||
pub archive_after_days: u32,
|
||||
#[serde(default = "default_purge_after_days")]
|
||||
pub purge_after_days: u32,
|
||||
#[serde(default = "default_conversation_retention_days")]
|
||||
pub conversation_retention_days: u32,
|
||||
#[serde(default = "default_embedding_provider")]
|
||||
pub embedding_provider: String,
|
||||
#[serde(default = "default_embedding_model")]
|
||||
pub embedding_model: String,
|
||||
#[serde(default = "default_embedding_dims")]
|
||||
pub embedding_dimensions: usize,
|
||||
#[serde(default = "default_vector_weight")]
|
||||
pub vector_weight: f64,
|
||||
#[serde(default = "default_keyword_weight")]
|
||||
pub keyword_weight: f64,
|
||||
#[serde(default = "default_min_relevance_score")]
|
||||
pub min_relevance_score: f64,
|
||||
#[serde(default = "default_cache_size")]
|
||||
pub embedding_cache_size: usize,
|
||||
#[serde(default = "default_chunk_size")]
|
||||
pub chunk_max_tokens: usize,
|
||||
#[serde(default)]
|
||||
pub response_cache_enabled: bool,
|
||||
#[serde(default = "default_response_cache_ttl")]
|
||||
pub response_cache_ttl_minutes: u32,
|
||||
#[serde(default = "default_response_cache_max")]
|
||||
pub response_cache_max_entries: usize,
|
||||
#[serde(default)]
|
||||
pub snapshot_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub snapshot_on_hygiene: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_hydrate: bool,
|
||||
#[serde(default)]
|
||||
pub sqlite_open_timeout_secs: Option<u64>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
defaults::default_true()
|
||||
}
|
||||
|
||||
fn default_embedding_provider() -> String {
|
||||
"ollama".into()
|
||||
}
|
||||
fn default_hygiene_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
fn default_archive_after_days() -> u32 {
|
||||
7
|
||||
}
|
||||
fn default_purge_after_days() -> u32 {
|
||||
30
|
||||
}
|
||||
fn default_conversation_retention_days() -> u32 {
|
||||
30
|
||||
}
|
||||
fn default_embedding_model() -> String {
|
||||
"nomic-embed-text:latest".into()
|
||||
}
|
||||
fn default_embedding_dims() -> usize {
|
||||
768
|
||||
}
|
||||
fn default_vector_weight() -> f64 {
|
||||
0.7
|
||||
}
|
||||
fn default_keyword_weight() -> f64 {
|
||||
0.3
|
||||
}
|
||||
fn default_min_relevance_score() -> f64 {
|
||||
0.4
|
||||
}
|
||||
fn default_cache_size() -> usize {
|
||||
10_000
|
||||
}
|
||||
fn default_chunk_size() -> usize {
|
||||
512
|
||||
}
|
||||
fn default_response_cache_ttl() -> u32 {
|
||||
60
|
||||
}
|
||||
fn default_response_cache_max() -> usize {
|
||||
5_000
|
||||
}
|
||||
|
||||
impl Default for MemoryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
backend: "sqlite".into(),
|
||||
auto_save: true,
|
||||
hygiene_enabled: default_hygiene_enabled(),
|
||||
archive_after_days: default_archive_after_days(),
|
||||
purge_after_days: default_purge_after_days(),
|
||||
conversation_retention_days: default_conversation_retention_days(),
|
||||
embedding_provider: default_embedding_provider(),
|
||||
embedding_model: default_embedding_model(),
|
||||
embedding_dimensions: default_embedding_dims(),
|
||||
vector_weight: default_vector_weight(),
|
||||
keyword_weight: default_keyword_weight(),
|
||||
min_relevance_score: default_min_relevance_score(),
|
||||
embedding_cache_size: default_cache_size(),
|
||||
chunk_max_tokens: default_chunk_size(),
|
||||
response_cache_enabled: false,
|
||||
response_cache_ttl_minutes: default_response_cache_ttl(),
|
||||
response_cache_max_entries: default_response_cache_max(),
|
||||
snapshot_enabled: false,
|
||||
snapshot_on_hygiene: false,
|
||||
auto_hydrate: true,
|
||||
sqlite_open_timeout_secs: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,8 +50,6 @@ impl Default for MultimodalConfig {
|
||||
pub struct BrowserComputerUseConfig {
|
||||
#[serde(default = "default_browser_computer_use_endpoint")]
|
||||
pub endpoint: String,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default = "default_browser_computer_use_timeout_ms")]
|
||||
pub timeout_ms: u64,
|
||||
#[serde(default)]
|
||||
@@ -76,7 +74,6 @@ impl Default for BrowserComputerUseConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
endpoint: default_browser_computer_use_endpoint(),
|
||||
api_key: None,
|
||||
timeout_ms: default_browser_computer_use_timeout_ms(),
|
||||
allow_remote_endpoint: false,
|
||||
window_allowlist: Vec::new(),
|
||||
@@ -153,27 +150,12 @@ fn default_http_timeout_secs() -> u64 {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct WebSearchConfig {
|
||||
/// Search provider. Valid values: `duckduckgo` (default, free), `brave` (requires `brave_api_key`),
|
||||
/// `parallel` (requires `parallel_api_key`).
|
||||
#[serde(default = "default_web_search_provider")]
|
||||
pub provider: String,
|
||||
/// API key for the Brave Search API. Set via `OPENHUMAN_BRAVE_API_KEY` / `BRAVE_API_KEY`.
|
||||
#[serde(default)]
|
||||
pub brave_api_key: Option<String>,
|
||||
/// API key for the Parallel Search API (<https://docs.parallel.ai>).
|
||||
/// Set via `OPENHUMAN_PARALLEL_API_KEY` / `PARALLEL_API_KEY`.
|
||||
#[serde(default)]
|
||||
pub parallel_api_key: Option<String>,
|
||||
#[serde(default = "default_web_search_max_results")]
|
||||
pub max_results: usize,
|
||||
#[serde(default = "default_web_search_timeout_secs")]
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
fn default_web_search_provider() -> String {
|
||||
"duckduckgo".into()
|
||||
}
|
||||
|
||||
fn default_web_search_max_results() -> usize {
|
||||
5
|
||||
}
|
||||
@@ -185,9 +167,6 @@ fn default_web_search_timeout_secs() -> u64 {
|
||||
impl Default for WebSearchConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
provider: default_web_search_provider(),
|
||||
brave_api_key: None,
|
||||
parallel_api_key: None,
|
||||
max_results: default_web_search_max_results(),
|
||||
timeout_secs: default_web_search_timeout_secs(),
|
||||
}
|
||||
@@ -198,8 +177,6 @@ impl Default for WebSearchConfig {
|
||||
pub struct ComposioConfig {
|
||||
#[serde(default)]
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub api_key: Option<String>,
|
||||
#[serde(default = "default_entity_id")]
|
||||
pub entity_id: String,
|
||||
}
|
||||
@@ -212,7 +189,6 @@ impl Default for ComposioConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
api_key: None,
|
||||
entity_id: default_entity_id(),
|
||||
}
|
||||
}
|
||||
@@ -262,8 +238,8 @@ impl Default for IntegrationToggle {
|
||||
/// Agent integration tools that proxy through the backend API.
|
||||
///
|
||||
/// The backend URL and auth token are **not** configurable here —
|
||||
/// they're always resolved from the core `config.api_url` /
|
||||
/// `config.api_key` (the same values every other part of the app uses).
|
||||
/// they're always resolved from the core `config.api_url` plus the
|
||||
/// app-session JWT.
|
||||
/// Composio in particular is unconditionally enabled and has no toggle:
|
||||
/// as long as the user is signed in, composio tools are available.
|
||||
///
|
||||
|
||||
@@ -28,7 +28,6 @@ pub struct Config {
|
||||
pub workspace_dir: PathBuf,
|
||||
#[serde(skip)]
|
||||
pub config_path: PathBuf,
|
||||
pub api_key: Option<String>,
|
||||
pub api_url: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_temperature: f64,
|
||||
@@ -70,9 +69,6 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub embedding_routes: Vec<EmbeddingRouteConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub query_classification: QueryClassificationConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub heartbeat: HeartbeatConfig,
|
||||
|
||||
@@ -115,9 +111,6 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub computer_control: ComputerControlConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub peripherals: PeripheralsConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub agents: HashMap<String, DelegateAgentConfig>,
|
||||
|
||||
@@ -137,9 +130,6 @@ pub struct Config {
|
||||
#[serde(default)]
|
||||
pub learning: LearningConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub orchestrator: OrchestratorConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub update: UpdateConfig,
|
||||
|
||||
@@ -219,7 +209,6 @@ impl Default for Config {
|
||||
Self {
|
||||
workspace_dir: openhuman_dir.join("workspace"),
|
||||
config_path: openhuman_dir.join("config.toml"),
|
||||
api_key: None,
|
||||
api_url: None,
|
||||
default_model: Some(DEFAULT_MODEL.to_string()),
|
||||
default_temperature: 0.7,
|
||||
@@ -248,15 +237,12 @@ impl Default for Config {
|
||||
proxy: ProxyConfig::default(),
|
||||
cost: CostConfig::default(),
|
||||
computer_control: ComputerControlConfig::default(),
|
||||
peripherals: PeripheralsConfig::default(),
|
||||
agents: HashMap::new(),
|
||||
local_ai: LocalAiConfig::default(),
|
||||
node: NodeConfig::default(),
|
||||
voice_server: VoiceServerConfig::default(),
|
||||
query_classification: QueryClassificationConfig::default(),
|
||||
integrations: IntegrationsConfig::default(),
|
||||
learning: LearningConfig::default(),
|
||||
orchestrator: OrchestratorConfig::default(),
|
||||
update: UpdateConfig::default(),
|
||||
dictation: DictationConfig::default(),
|
||||
onboarding_completed: false,
|
||||
|
||||
@@ -11,7 +11,6 @@ const DEFAULT_ONBOARDING_FLAG_NAME: &str = ".skip_onboarding";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ModelSettingsUpdate {
|
||||
api_key: Option<String>,
|
||||
api_url: Option<String>,
|
||||
default_model: Option<String>,
|
||||
default_temperature: Option<f64>,
|
||||
@@ -230,9 +229,8 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
||||
"update_model_settings" => ControllerSchema {
|
||||
namespace: "config",
|
||||
function: "update_model_settings",
|
||||
description: "Update model and API connection settings.",
|
||||
description: "Update model and backend connection settings.",
|
||||
inputs: vec![
|
||||
optional_string("api_key", "Provider API key."),
|
||||
optional_string("api_url", "Backend API URL."),
|
||||
optional_string("default_model", "Default model id."),
|
||||
FieldSchema {
|
||||
@@ -559,7 +557,6 @@ fn handle_update_model_settings(params: Map<String, Value>) -> ControllerFuture
|
||||
Box::pin(async move {
|
||||
let update = deserialize_params::<ModelSettingsUpdate>(params)?;
|
||||
let patch = config_rpc::ModelSettingsPatch {
|
||||
api_key: update.api_key,
|
||||
api_url: update.api_url,
|
||||
default_model: update.default_model,
|
||||
default_temperature: update.default_temperature,
|
||||
@@ -907,13 +904,11 @@ mod tests {
|
||||
#[test]
|
||||
fn deserialize_params_parses_model_settings_update() {
|
||||
let mut m = Map::new();
|
||||
m.insert("api_key".into(), Value::String("sk-123".into()));
|
||||
m.insert(
|
||||
"default_temperature".into(),
|
||||
Value::Number(serde_json::Number::from_f64(0.7).unwrap()),
|
||||
);
|
||||
let out: ModelSettingsUpdate = deserialize_params(m).unwrap();
|
||||
assert_eq!(out.api_key.as_deref(), Some("sk-123"));
|
||||
assert_eq!(out.default_temperature, Some(0.7));
|
||||
assert!(out.api_url.is_none());
|
||||
assert!(out.default_model.is_none());
|
||||
|
||||
@@ -19,7 +19,6 @@ pub fn settings_section_json(
|
||||
let cfg = &snap.config;
|
||||
let settings = match section {
|
||||
"model" => json!({
|
||||
"api_key": cfg.get("api_key"),
|
||||
"api_url": cfg.get("api_url"),
|
||||
"default_model": cfg.get("default_model"),
|
||||
"default_temperature": cfg.get("default_temperature"),
|
||||
@@ -57,7 +56,6 @@ mod tests {
|
||||
fn sample_snapshot() -> ConfigSnapshotFields {
|
||||
ConfigSnapshotFields {
|
||||
config: json!({
|
||||
"api_key": "sk-xxx",
|
||||
"api_url": "https://api.example.com",
|
||||
"default_model": "gpt-4",
|
||||
"default_temperature": 0.7,
|
||||
@@ -75,7 +73,6 @@ mod tests {
|
||||
let snap = sample_snapshot();
|
||||
let v = settings_section_json("model", &snap, vec!["a".into()]);
|
||||
assert_eq!(v["result"]["section"], "model");
|
||||
assert_eq!(v["result"]["settings"]["api_key"], "sk-xxx");
|
||||
assert_eq!(v["result"]["settings"]["default_model"], "gpt-4");
|
||||
assert_eq!(v["result"]["workspace_dir"], "/tmp/ws");
|
||||
assert_eq!(v["result"]["config_path"], "/tmp/config.toml");
|
||||
@@ -140,9 +137,8 @@ mod tests {
|
||||
config_path: "/tmp/cfg.toml".into(),
|
||||
};
|
||||
let v = settings_section_json("model", &snap, vec![]);
|
||||
// `default_model` present; the others (api_key/api_url/default_temperature) null.
|
||||
// `default_model` present; the others (api_url/default_temperature) null.
|
||||
assert_eq!(v["result"]["settings"]["default_model"], "gpt-4");
|
||||
assert!(v["result"]["settings"]["api_key"].is_null());
|
||||
assert!(v["result"]["settings"]["api_url"].is_null());
|
||||
}
|
||||
|
||||
|
||||
@@ -191,14 +191,22 @@ fn check_config_semantics(config: &Config, items: &mut Vec<DiagnosticItem>) {
|
||||
));
|
||||
}
|
||||
|
||||
// API key / session
|
||||
if config.api_key.is_some() {
|
||||
items.push(DiagnosticItem::ok(cat, "API key configured"));
|
||||
} else {
|
||||
items.push(DiagnosticItem::warn(
|
||||
cat,
|
||||
"no api_key set (may use app session JWT from auth profile)",
|
||||
));
|
||||
match crate::api::jwt::get_session_token(config) {
|
||||
Ok(Some(token)) if !token.trim().is_empty() => {
|
||||
items.push(DiagnosticItem::ok(cat, "signed in with app session JWT"));
|
||||
}
|
||||
Ok(_) => {
|
||||
items.push(DiagnosticItem::warn(
|
||||
cat,
|
||||
"no app session JWT — not signed in",
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
items.push(DiagnosticItem::error(
|
||||
cat,
|
||||
format!("failed to read app session JWT: {err}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Model configured
|
||||
|
||||
@@ -18,25 +18,20 @@ use super::{NoopEmbedding, OllamaEmbedding, OpenAiEmbedding};
|
||||
/// keyword-only search.
|
||||
pub fn create_embedding_provider(
|
||||
provider: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
dims: usize,
|
||||
) -> anyhow::Result<Box<dyn EmbeddingProvider>> {
|
||||
match provider {
|
||||
"ollama" => Ok(Box::new(OllamaEmbedding::new("", model, dims))),
|
||||
"openai" => {
|
||||
let key = api_key.unwrap_or("");
|
||||
Ok(Box::new(OpenAiEmbedding::new(
|
||||
"https://api.openai.com",
|
||||
key,
|
||||
model,
|
||||
dims,
|
||||
)))
|
||||
}
|
||||
"openai" => Ok(Box::new(OpenAiEmbedding::new(
|
||||
"https://api.openai.com",
|
||||
"",
|
||||
model,
|
||||
dims,
|
||||
))),
|
||||
name if name.starts_with("custom:") => {
|
||||
let base_url = name.strip_prefix("custom:").unwrap_or("");
|
||||
let key = api_key.unwrap_or("");
|
||||
Ok(Box::new(OpenAiEmbedding::new(base_url, key, model, dims)))
|
||||
Ok(Box::new(OpenAiEmbedding::new(base_url, "", model, dims)))
|
||||
}
|
||||
"none" => Ok(Box::new(NoopEmbedding)),
|
||||
unknown => Err(anyhow::anyhow!(
|
||||
|
||||
@@ -60,43 +60,34 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn factory_ollama() {
|
||||
let p = create_embedding_provider("ollama", None, DEFAULT_OLLAMA_MODEL, 768).unwrap();
|
||||
let p = create_embedding_provider("ollama", DEFAULT_OLLAMA_MODEL, 768).unwrap();
|
||||
assert_eq!(p.name(), "ollama");
|
||||
assert_eq!(p.dimensions(), 768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_openai() {
|
||||
let p = create_embedding_provider("openai", Some("key"), "text-embedding-3-small", 1536)
|
||||
.unwrap();
|
||||
assert_eq!(p.name(), "openai");
|
||||
assert_eq!(p.dimensions(), 1536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_openai_no_api_key() {
|
||||
let p = create_embedding_provider("openai", None, "text-embedding-3-small", 1536).unwrap();
|
||||
let p = create_embedding_provider("openai", "text-embedding-3-small", 1536).unwrap();
|
||||
assert_eq!(p.name(), "openai");
|
||||
assert_eq!(p.dimensions(), 1536);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_url() {
|
||||
let p =
|
||||
create_embedding_provider("custom:http://localhost:1234", None, "model", 768).unwrap();
|
||||
let p = create_embedding_provider("custom:http://localhost:1234", "model", 768).unwrap();
|
||||
assert_eq!(p.name(), "openai"); // OpenAI-compatible under the hood
|
||||
assert_eq!(p.dimensions(), 768);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_custom_empty_url() {
|
||||
let p = create_embedding_provider("custom:", None, "model", 768).unwrap();
|
||||
let p = create_embedding_provider("custom:", "model", 768).unwrap();
|
||||
assert_eq!(p.name(), "openai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factory_none() {
|
||||
let p = create_embedding_provider("none", None, "", 0).unwrap();
|
||||
let p = create_embedding_provider("none", "", 0).unwrap();
|
||||
assert_eq!(p.name(), "none");
|
||||
assert_eq!(p.dimensions(), 0);
|
||||
}
|
||||
@@ -105,7 +96,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn factory_unknown_provider_errors() {
|
||||
let result = create_embedding_provider("cohere", None, "model", 1536);
|
||||
let result = create_embedding_provider("cohere", "model", 1536);
|
||||
let msg = result.err().expect("should be an error").to_string();
|
||||
assert!(
|
||||
msg.contains("cohere"),
|
||||
@@ -116,7 +107,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn factory_empty_string_errors() {
|
||||
let result = create_embedding_provider("", None, "model", 1536);
|
||||
let result = create_embedding_provider("", "model", 1536);
|
||||
assert!(result
|
||||
.err()
|
||||
.expect("should error")
|
||||
@@ -126,7 +117,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn factory_fastembed_errors() {
|
||||
let result = create_embedding_provider("fastembed", None, "BGESmallENV15", 384);
|
||||
let result = create_embedding_provider("fastembed", "BGESmallENV15", 384);
|
||||
assert!(result
|
||||
.err()
|
||||
.expect("should error")
|
||||
|
||||
@@ -176,9 +176,6 @@ impl IntegrationClient {
|
||||
/// - auth token → [`crate::api::jwt::get_session_token`], i.e. the
|
||||
/// app-session JWT written by `auth_store_session` — the same token
|
||||
/// that billing, team, webhooks, referral, memory, etc. all use.
|
||||
/// As a last-ditch fallback we also honour `config.api_key` so
|
||||
/// non-interactive sidecar deployments that set `OPENHUMAN_API_KEY`
|
||||
/// still work.
|
||||
///
|
||||
/// There are no per-feature toggles for the shared client itself —
|
||||
/// callers that need a kill switch (e.g. twilio, google_places,
|
||||
@@ -203,17 +200,7 @@ pub fn build_client(config: &crate::openhuman::config::Config) -> Option<Arc<Int
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback: config.api_key for headless / CI deployments.
|
||||
let auth_token = session_token.or_else(|| {
|
||||
config
|
||||
.api_key
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
});
|
||||
|
||||
match auth_token {
|
||||
match session_token {
|
||||
Some(token) => {
|
||||
tracing::debug!(
|
||||
backend_url = %backend_url,
|
||||
@@ -224,7 +211,7 @@ pub fn build_client(config: &crate::openhuman::config::Config) -> Option<Arc<Int
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[integrations] no auth token available — user is not signed in \
|
||||
(no app-session JWT and no config.api_key fallback)"
|
||||
(no app-session JWT)"
|
||||
);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -59,25 +59,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn build_client_returns_none_when_no_auth_token() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.api_key = None;
|
||||
assert!(build_client(&config).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_client_uses_core_api_key() {
|
||||
// No per-integration config exists any more — the client is
|
||||
// built solely from the core `config.api_key` / `config.api_url`.
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.api_key = Some("root-token".into());
|
||||
config.api_url = Some("https://api.example.test".into());
|
||||
assert!(build_client(&config).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_client_rejects_whitespace_only_api_key() {
|
||||
let mut config = crate::openhuman::config::Config::default();
|
||||
config.api_key = Some(" ".into());
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = crate::openhuman::config::Config {
|
||||
workspace_dir: tmp.path().join("workspace"),
|
||||
config_path: tmp.path().join("config.toml"),
|
||||
..crate::openhuman::config::Config::default()
|
||||
};
|
||||
assert!(build_client(&config).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,26 +30,21 @@ fn truncate_chars(s: &str, max_chars: usize) -> (&str, bool) {
|
||||
// ── Response types ──────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResponse {
|
||||
#[serde(rename = "searchId", default)]
|
||||
pub(crate) struct SearchResponse {
|
||||
#[serde(rename = "searchId")]
|
||||
#[allow(dead_code)]
|
||||
search_id: String,
|
||||
#[serde(default)]
|
||||
results: Vec<SearchResultItem>,
|
||||
#[serde(rename = "costUsd", default)]
|
||||
cost_usd: f64,
|
||||
pub(crate) search_id: String,
|
||||
pub(crate) results: Vec<SearchResultItem>,
|
||||
#[serde(rename = "costUsd")]
|
||||
pub(crate) cost_usd: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SearchResultItem {
|
||||
#[serde(default)]
|
||||
url: String,
|
||||
#[serde(default)]
|
||||
title: String,
|
||||
#[serde(default)]
|
||||
publish_date: Option<String>,
|
||||
#[serde(default)]
|
||||
excerpts: Vec<String>,
|
||||
pub(crate) struct SearchResultItem {
|
||||
pub(crate) url: String,
|
||||
pub(crate) title: String,
|
||||
pub(crate) publish_date: Option<String>,
|
||||
pub(crate) excerpts: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -486,6 +481,33 @@ mod tests {
|
||||
assert!(result.is_error);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_rejects_missing_search_id() {
|
||||
let json = r#"{
|
||||
"results": [],
|
||||
"costUsd": 0.01
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<SearchResponse>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_rejects_missing_results() {
|
||||
let json = r#"{
|
||||
"searchId": "s123",
|
||||
"costUsd": 0.01
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<SearchResponse>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_rejects_missing_cost_usd() {
|
||||
let json = r#"{
|
||||
"searchId": "s123",
|
||||
"results": []
|
||||
}"#;
|
||||
assert!(serde_json::from_str::<SearchResponse>(json).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_response_deserializes() {
|
||||
let json = r#"{
|
||||
|
||||
@@ -261,17 +261,11 @@ async fn write_profile_md(
|
||||
/// Ask the backend LLM to distil the raw LinkedIn Markdown into a
|
||||
/// concise, high-signal profile document suitable for agent context.
|
||||
async fn summarise_profile_with_llm(config: &Config, raw_md: &str) -> anyhow::Result<String> {
|
||||
use crate::api::jwt::get_session_token;
|
||||
use crate::openhuman::providers::ops::{
|
||||
create_backend_inference_provider, ProviderRuntimeOptions,
|
||||
};
|
||||
|
||||
let token = get_session_token(config)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read session token: {e}"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("no session token for LLM call"))?;
|
||||
|
||||
let provider = create_backend_inference_provider(
|
||||
Some(&token),
|
||||
config.api_url.as_deref(),
|
||||
&ProviderRuntimeOptions::default(),
|
||||
)?;
|
||||
|
||||
@@ -71,7 +71,6 @@ pub async fn agent_chat_simple(
|
||||
};
|
||||
|
||||
let provider = providers::create_routed_provider_with_options(
|
||||
effective.api_key.as_deref(),
|
||||
effective.api_url.as_deref(),
|
||||
&effective.reliability,
|
||||
&effective.model_routes,
|
||||
|
||||
@@ -28,18 +28,11 @@ pub fn effective_memory_backend_name(
|
||||
}
|
||||
|
||||
/// Create a standard memory instance based on the provided configuration.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - The memory configuration (provider, model, etc.).
|
||||
/// * `workspace_dir` - The directory where memory data should be stored.
|
||||
/// * `api_key` - Optional API key for external embedding providers.
|
||||
pub fn create_memory(
|
||||
config: &MemoryConfig,
|
||||
workspace_dir: &Path,
|
||||
api_key: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Memory>> {
|
||||
create_memory_with_storage_and_routes(config, &[], None, workspace_dir, api_key)
|
||||
create_memory_with_storage_and_routes(config, &[], None, workspace_dir)
|
||||
}
|
||||
|
||||
/// Create a memory instance with an optional storage provider configuration.
|
||||
@@ -47,34 +40,23 @@ pub fn create_memory_with_storage(
|
||||
config: &MemoryConfig,
|
||||
storage_provider: Option<&StorageProviderConfig>,
|
||||
workspace_dir: &Path,
|
||||
api_key: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Memory>> {
|
||||
create_memory_with_storage_and_routes(config, &[], storage_provider, workspace_dir, api_key)
|
||||
create_memory_with_storage_and_routes(config, &[], storage_provider, workspace_dir)
|
||||
}
|
||||
|
||||
/// The most comprehensive factory function for creating a memory instance.
|
||||
///
|
||||
/// This function initializes the embedding provider and then creates a
|
||||
/// `UnifiedMemory` instance.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `config` - Core memory configuration.
|
||||
/// * `_embedding_routes` - Configuration for routing embeddings (currently unused).
|
||||
/// * `_storage_provider` - Configuration for the storage backend (currently unused).
|
||||
/// * `workspace_dir` - The directory for storage.
|
||||
/// * `api_key` - API key for the embedding provider.
|
||||
pub fn create_memory_with_storage_and_routes(
|
||||
config: &MemoryConfig,
|
||||
_embedding_routes: &[EmbeddingRouteConfig],
|
||||
_storage_provider: Option<&StorageProviderConfig>,
|
||||
workspace_dir: &Path,
|
||||
api_key: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Memory>> {
|
||||
// 1. Create the embedding provider based on config (Local vs Remote).
|
||||
let embedder: Arc<dyn EmbeddingProvider> = Arc::from(embeddings::create_embedding_provider(
|
||||
&config.embedding_provider,
|
||||
api_key,
|
||||
&config.embedding_model,
|
||||
config.embedding_dimensions,
|
||||
)?);
|
||||
|
||||
@@ -15,27 +15,19 @@ use std::path::PathBuf;
|
||||
|
||||
const PROVIDER_LABEL: &str = "OpenHuman";
|
||||
|
||||
/// Routes chat to `config.api_url` + `/openai` with `Authorization: Bearer` from the `app-session` profile (or `config.api_key` when set).
|
||||
/// Routes chat to `config.api_url` + `/openai` with `Authorization: Bearer` from the `app-session` profile.
|
||||
pub struct OpenHumanBackendProvider {
|
||||
options: ProviderRuntimeOptions,
|
||||
api_url: Option<String>,
|
||||
config_api_key: Option<String>,
|
||||
}
|
||||
|
||||
impl OpenHumanBackendProvider {
|
||||
pub fn new(
|
||||
api_url: Option<&str>,
|
||||
api_key: Option<&str>,
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> Self {
|
||||
pub fn new(api_url: Option<&str>, options: &ProviderRuntimeOptions) -> Self {
|
||||
Self {
|
||||
options: options.clone(),
|
||||
api_url: api_url
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
config_api_key: api_key
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,14 +50,7 @@ impl OpenHumanBackendProvider {
|
||||
{
|
||||
return Ok(t);
|
||||
}
|
||||
if let Some(ref k) = self.config_api_key {
|
||||
if !k.is_empty() {
|
||||
return Ok(k.clone());
|
||||
}
|
||||
}
|
||||
anyhow::bail!(
|
||||
"No backend session: store a JWT via auth (app-session) or set api_key in config"
|
||||
)
|
||||
anyhow::bail!("No backend session: store a JWT via auth (app-session)")
|
||||
}
|
||||
|
||||
fn base_url(&self) -> anyhow::Result<String> {
|
||||
|
||||
@@ -147,70 +147,26 @@ pub async fn api_error(provider: &str, response: reqwest::Response) -> anyhow::E
|
||||
anyhow::anyhow!("{provider} API error ({status}): {sanitized}")
|
||||
}
|
||||
|
||||
fn resolve_provider_credential(_name: &str, credential_override: Option<&str>) -> Option<String> {
|
||||
if let Some(raw) = credential_override.map(str::trim).filter(|s| !s.is_empty()) {
|
||||
return Some(raw.to_owned());
|
||||
}
|
||||
for env_var in ["OPENHUMAN_API_KEY", "API_KEY"] {
|
||||
if let Ok(value) = std::env::var(env_var) {
|
||||
let value = value.trim();
|
||||
if !value.is_empty() {
|
||||
return Some(value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Create the OpenHuman backend inference client (session JWT or `api_key`).
|
||||
/// Create the OpenHuman backend inference client (session JWT only).
|
||||
pub fn create_backend_inference_provider(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
let resolved = resolve_provider_credential(INFERENCE_BACKEND_ID, api_key)
|
||||
.map(|v| String::from_utf8(v.into_bytes()).unwrap_or_default());
|
||||
let key = resolved.as_deref();
|
||||
Ok(Box::new(openhuman_backend::OpenHumanBackendProvider::new(
|
||||
api_url, key, options,
|
||||
api_url, options,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias (name ignored).
|
||||
pub fn create_provider_with_options(
|
||||
_name: &str,
|
||||
api_key: Option<&str>,
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
create_backend_inference_provider(api_key, None, options)
|
||||
}
|
||||
|
||||
/// Backwards-compatible alias (name ignored).
|
||||
pub fn create_provider_with_url(
|
||||
_name: &str,
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
create_backend_inference_provider(api_key, api_url, &ProviderRuntimeOptions::default())
|
||||
}
|
||||
|
||||
/// Create provider chain with retry and fallback behavior.
|
||||
pub fn create_resilient_provider(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
reliability: &crate::openhuman::config::ReliabilityConfig,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
create_resilient_provider_with_options(
|
||||
api_key,
|
||||
api_url,
|
||||
reliability,
|
||||
&ProviderRuntimeOptions::default(),
|
||||
)
|
||||
create_resilient_provider_with_options(api_url, reliability, &ProviderRuntimeOptions::default())
|
||||
}
|
||||
|
||||
/// Create provider chain with retry/fallback behavior and auth runtime options.
|
||||
pub fn create_resilient_provider_with_options(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
reliability: &crate::openhuman::config::ReliabilityConfig,
|
||||
options: &ProviderRuntimeOptions,
|
||||
@@ -221,7 +177,7 @@ pub fn create_resilient_provider_with_options(
|
||||
);
|
||||
}
|
||||
|
||||
let primary_provider = create_backend_inference_provider(api_key, api_url, options)?;
|
||||
let primary_provider = create_backend_inference_provider(api_url, options)?;
|
||||
let providers: Vec<(String, Box<dyn Provider>)> =
|
||||
vec![(INFERENCE_BACKEND_ID.to_string(), primary_provider)];
|
||||
|
||||
@@ -230,7 +186,6 @@ pub fn create_resilient_provider_with_options(
|
||||
reliability.provider_retries,
|
||||
reliability.provider_backoff_ms,
|
||||
)
|
||||
.with_api_keys(reliability.api_keys.clone())
|
||||
.with_model_fallbacks(reliability.model_fallbacks.clone());
|
||||
|
||||
Ok(Box::new(reliable))
|
||||
@@ -238,14 +193,12 @@ pub fn create_resilient_provider_with_options(
|
||||
|
||||
/// Create a RouterProvider if model routes are configured, otherwise return a resilient provider.
|
||||
pub fn create_routed_provider(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
reliability: &crate::openhuman::config::ReliabilityConfig,
|
||||
model_routes: &[crate::openhuman::config::ModelRouteConfig],
|
||||
default_model: &str,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
create_routed_provider_with_options(
|
||||
api_key,
|
||||
api_url,
|
||||
reliability,
|
||||
model_routes,
|
||||
@@ -255,7 +208,6 @@ pub fn create_routed_provider(
|
||||
}
|
||||
|
||||
pub fn create_routed_provider_with_options(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
reliability: &crate::openhuman::config::ReliabilityConfig,
|
||||
model_routes: &[crate::openhuman::config::ModelRouteConfig],
|
||||
@@ -263,10 +215,10 @@ pub fn create_routed_provider_with_options(
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
if model_routes.is_empty() {
|
||||
return create_resilient_provider_with_options(api_key, api_url, reliability, options);
|
||||
return create_resilient_provider_with_options(api_url, reliability, options);
|
||||
}
|
||||
|
||||
let backend = create_backend_inference_provider(api_key, api_url, options)?;
|
||||
let backend = create_backend_inference_provider(api_url, options)?;
|
||||
let providers: Vec<(String, Box<dyn Provider>)> =
|
||||
vec![(INFERENCE_BACKEND_ID.to_string(), backend)];
|
||||
|
||||
@@ -301,12 +253,11 @@ pub fn create_routed_provider_with_options(
|
||||
/// Telemetry for every routing decision is emitted at `INFO` level under the
|
||||
/// `"routing"` tracing target.
|
||||
pub fn create_intelligent_routing_provider(
|
||||
api_key: Option<&str>,
|
||||
api_url: Option<&str>,
|
||||
config: &crate::openhuman::config::Config,
|
||||
options: &ProviderRuntimeOptions,
|
||||
) -> anyhow::Result<Box<dyn Provider>> {
|
||||
let remote = create_backend_inference_provider(api_key, api_url, options)?;
|
||||
let remote = create_backend_inference_provider(api_url, options)?;
|
||||
let default_model = config
|
||||
.default_model
|
||||
.as_deref()
|
||||
@@ -368,18 +319,9 @@ mod tests {
|
||||
#[test]
|
||||
fn factory_backend() {
|
||||
assert!(create_backend_inference_provider(
|
||||
Some("jwt"),
|
||||
Some("https://api.example.com"),
|
||||
&ProviderRuntimeOptions::default()
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_create_provider_with_url_ignores_name() {
|
||||
assert!(
|
||||
create_provider_with_url("anything", Some("jwt"), Some("https://api.example.com"))
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
//! use crate::openhuman::providers::create_backend_inference_provider;
|
||||
//! use crate::openhuman::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
|
||||
//!
|
||||
//! let remote = create_backend_inference_provider(api_key, api_url, &opts)?;
|
||||
//! let remote = create_backend_inference_provider(api_url, &opts)?;
|
||||
//! let provider = routing::new_provider(remote, &config.local_ai, &config.default_model);
|
||||
//! ```
|
||||
|
||||
|
||||
@@ -355,7 +355,6 @@ pub async fn thread_generate_title(
|
||||
};
|
||||
|
||||
let provider = match providers::create_intelligent_routing_provider(
|
||||
config.api_key.as_deref(),
|
||||
config.api_url.as_deref(),
|
||||
&config,
|
||||
&provider_runtime_options,
|
||||
|
||||
@@ -141,7 +141,7 @@ impl Tool for CompleteOnboardingTool {
|
||||
```\n\
|
||||
{\n\
|
||||
\"authenticated\": true, // bool — JWT present\n\
|
||||
\"auth_source\": \"session_token\", // \"session_token\" | \"legacy_api_key\" | null\n\
|
||||
\"auth_source\": \"session_token\", // \"session_token\" | null\n\
|
||||
\"default_model\": \"reasoning-v1\", // string\n\
|
||||
\"channels_connected\": [\"telegram\"], // string[] — connected messaging platforms\n\
|
||||
\"active_channel\": \"web\", // preferred channel for proactive messages\n\
|
||||
@@ -301,16 +301,8 @@ async fn check_status() -> anyhow::Result<ToolResult> {
|
||||
|
||||
/// Detect whether the user is authenticated for the welcome flow.
|
||||
///
|
||||
/// Two possible auth sources, in priority order:
|
||||
///
|
||||
/// 1. The `app-session:default` profile in `auth-profiles.json` —
|
||||
/// the canonical inference credential, populated by the desktop
|
||||
/// OAuth deep-link flow's `exchange_token` Rust command. This is
|
||||
/// where every production inference RPC reads from.
|
||||
/// 2. `config.api_key` — legacy free-form provider key field, kept
|
||||
/// for CI / dev setups that bypass the desktop login flow.
|
||||
///
|
||||
/// Either one counts as "authenticated".
|
||||
/// Authentication is based on the `app-session:default` profile in
|
||||
/// `auth-profiles.json`, populated by the desktop OAuth deep-link flow.
|
||||
///
|
||||
/// Returned as `(is_authenticated, auth_source_json)` so callers can
|
||||
/// both gate behaviour on the bool and embed the source label in a
|
||||
@@ -320,12 +312,9 @@ pub(crate) fn detect_auth(config: &Config) -> (bool, Value) {
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|t| !t.is_empty());
|
||||
let has_legacy_api_key = config.api_key.as_ref().is_some_and(|k| !k.is_empty());
|
||||
let is_authenticated = has_session_jwt || has_legacy_api_key;
|
||||
let is_authenticated = has_session_jwt;
|
||||
let auth_source: Value = if has_session_jwt {
|
||||
Value::String("session_token".to_string())
|
||||
} else if has_legacy_api_key {
|
||||
Value::String("legacy_api_key".to_string())
|
||||
} else {
|
||||
Value::Null
|
||||
};
|
||||
@@ -399,12 +388,7 @@ pub(crate) fn build_status_snapshot(
|
||||
channels_connected.push("qq");
|
||||
}
|
||||
|
||||
let composio_enabled = config.composio.enabled
|
||||
&& config
|
||||
.composio
|
||||
.api_key
|
||||
.as_ref()
|
||||
.is_some_and(|k| !k.is_empty());
|
||||
let composio_enabled = config.composio.enabled;
|
||||
|
||||
let delegate_agents: Vec<&str> = config.agents.keys().map(|s| s.as_str()).collect();
|
||||
|
||||
@@ -463,9 +447,10 @@ pub(crate) fn build_status_snapshot(
|
||||
///
|
||||
/// ## Auth requirement
|
||||
///
|
||||
/// Requires the user to be authenticated. If there is no valid session
|
||||
/// JWT or legacy API key, the call is rejected with an explanation so
|
||||
/// the agent can instruct the user to log in.
|
||||
/// Requires the user to be authenticated with a valid session JWT.
|
||||
/// If `detect_auth()` cannot resolve an active app-session token, the
|
||||
/// call is rejected with an explanation so the agent can instruct the
|
||||
/// user to log in.
|
||||
async fn complete() -> anyhow::Result<ToolResult> {
|
||||
let mut config = Config::load_or_init()
|
||||
.await
|
||||
|
||||
@@ -17,8 +17,6 @@ use std::time::Duration;
|
||||
pub struct DelegateTool {
|
||||
agents: Arc<HashMap<String, DelegateAgentConfig>>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
/// Global credential fallback (from config.api_key)
|
||||
fallback_credential: Option<String>,
|
||||
/// Provider runtime options inherited from root config.
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions,
|
||||
/// Depth at which this tool instance lives in the delegation chain.
|
||||
@@ -28,12 +26,10 @@ pub struct DelegateTool {
|
||||
impl DelegateTool {
|
||||
pub fn new(
|
||||
agents: HashMap<String, DelegateAgentConfig>,
|
||||
fallback_credential: Option<String>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
) -> Self {
|
||||
Self::new_with_options(
|
||||
agents,
|
||||
fallback_credential,
|
||||
security,
|
||||
providers::ProviderRuntimeOptions::default(),
|
||||
)
|
||||
@@ -41,14 +37,12 @@ impl DelegateTool {
|
||||
|
||||
pub fn new_with_options(
|
||||
agents: HashMap<String, DelegateAgentConfig>,
|
||||
fallback_credential: Option<String>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions,
|
||||
) -> Self {
|
||||
Self {
|
||||
agents: Arc::new(agents),
|
||||
security,
|
||||
fallback_credential,
|
||||
provider_runtime_options,
|
||||
depth: 0,
|
||||
}
|
||||
@@ -59,13 +53,11 @@ impl DelegateTool {
|
||||
/// their DelegateTool via this method with `depth: parent.depth + 1`.
|
||||
pub fn with_depth(
|
||||
agents: HashMap<String, DelegateAgentConfig>,
|
||||
fallback_credential: Option<String>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
depth: u32,
|
||||
) -> Self {
|
||||
Self::with_depth_and_options(
|
||||
agents,
|
||||
fallback_credential,
|
||||
security,
|
||||
depth,
|
||||
providers::ProviderRuntimeOptions::default(),
|
||||
@@ -74,7 +66,6 @@ impl DelegateTool {
|
||||
|
||||
pub fn with_depth_and_options(
|
||||
agents: HashMap<String, DelegateAgentConfig>,
|
||||
fallback_credential: Option<String>,
|
||||
security: Arc<SecurityPolicy>,
|
||||
depth: u32,
|
||||
provider_runtime_options: providers::ProviderRuntimeOptions,
|
||||
@@ -82,7 +73,6 @@ impl DelegateTool {
|
||||
Self {
|
||||
agents: Arc::new(agents),
|
||||
security,
|
||||
fallback_credential,
|
||||
provider_runtime_options,
|
||||
depth,
|
||||
}
|
||||
@@ -194,16 +184,7 @@ impl Tool for DelegateTool {
|
||||
return Ok(ToolResult::error(error));
|
||||
}
|
||||
|
||||
// Create provider for this agent
|
||||
let provider_credential_owned = agent_config
|
||||
.api_key
|
||||
.clone()
|
||||
.or_else(|| self.fallback_credential.clone());
|
||||
#[allow(clippy::option_as_ref_deref)]
|
||||
let provider_credential = provider_credential_owned.as_ref().map(String::as_str);
|
||||
|
||||
let provider: Box<dyn Provider> = match providers::create_backend_inference_provider(
|
||||
provider_credential,
|
||||
None,
|
||||
&self.provider_runtime_options,
|
||||
) {
|
||||
@@ -282,7 +263,6 @@ mod tests {
|
||||
DelegateAgentConfig {
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: Some("You are a research assistant.".to_string()),
|
||||
api_key: None,
|
||||
temperature: Some(0.3),
|
||||
max_depth: 3,
|
||||
},
|
||||
@@ -292,7 +272,6 @@ mod tests {
|
||||
DelegateAgentConfig {
|
||||
model: crate::openhuman::config::DEFAULT_MODEL.to_string(),
|
||||
system_prompt: None,
|
||||
api_key: Some("delegate-test-credential".to_string()),
|
||||
temperature: None,
|
||||
max_depth: 2,
|
||||
},
|
||||
@@ -302,7 +281,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn name_and_schema() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
assert_eq!(tool.name(), "delegate");
|
||||
let schema = tool.parameters_schema();
|
||||
assert!(schema["properties"]["agent"].is_object());
|
||||
@@ -318,13 +297,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn description_not_empty() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
assert!(!tool.description().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_lists_agent_names() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let schema = tool.parameters_schema();
|
||||
let desc = schema["properties"]["agent"]["description"]
|
||||
.as_str()
|
||||
@@ -334,21 +313,21 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_agent_param() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let result = tool.execute(json!({"prompt": "test"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_prompt_param() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let result = tool.execute(json!({"agent": "researcher"})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_agent_returns_error() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let result = tool
|
||||
.execute(json!({"agent": "nonexistent", "prompt": "test"}))
|
||||
.await
|
||||
@@ -359,7 +338,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn depth_limit_enforced() {
|
||||
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 3);
|
||||
let tool = DelegateTool::with_depth(sample_agents(), test_security(), 3);
|
||||
let result = tool
|
||||
.execute(json!({"agent": "researcher", "prompt": "test"}))
|
||||
.await
|
||||
@@ -371,7 +350,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn depth_limit_per_agent() {
|
||||
// coder has max_depth=2, so depth=2 should be blocked
|
||||
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 2);
|
||||
let tool = DelegateTool::with_depth(sample_agents(), test_security(), 2);
|
||||
let result = tool
|
||||
.execute(json!({"agent": "coder", "prompt": "test"}))
|
||||
.await
|
||||
@@ -382,7 +361,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_agents_schema() {
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security());
|
||||
let tool = DelegateTool::new(HashMap::new(), test_security());
|
||||
let schema = tool.parameters_schema();
|
||||
let desc = schema["properties"]["agent"]["description"]
|
||||
.as_str()
|
||||
@@ -392,7 +371,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn blank_agent_rejected() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let result = tool
|
||||
.execute(json!({"agent": " ", "prompt": "test"}))
|
||||
.await
|
||||
@@ -403,7 +382,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn blank_prompt_rejected() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
let result = tool
|
||||
.execute(json!({"agent": "researcher", "prompt": " \t "}))
|
||||
.await
|
||||
@@ -414,7 +393,7 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn whitespace_agent_name_trimmed_and_found() {
|
||||
let tool = DelegateTool::new(sample_agents(), None, test_security());
|
||||
let tool = DelegateTool::new(sample_agents(), test_security());
|
||||
// " researcher " with surrounding whitespace — after trim becomes "researcher"
|
||||
let result = tool
|
||||
.execute(json!({"agent": " researcher ", "prompt": "test"}))
|
||||
@@ -431,7 +410,7 @@ mod tests {
|
||||
autonomy: AutonomyLevel::ReadOnly,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = DelegateTool::new(sample_agents(), None, readonly);
|
||||
let tool = DelegateTool::new(sample_agents(), readonly);
|
||||
let result = tool
|
||||
.execute(json!({"agent": "researcher", "prompt": "test"}))
|
||||
.await
|
||||
@@ -446,7 +425,7 @@ mod tests {
|
||||
max_actions_per_hour: 0,
|
||||
..SecurityPolicy::default()
|
||||
});
|
||||
let tool = DelegateTool::new(sample_agents(), None, limited);
|
||||
let tool = DelegateTool::new(sample_agents(), limited);
|
||||
let result = tool
|
||||
.execute(json!({"agent": "researcher", "prompt": "test"}))
|
||||
.await
|
||||
@@ -463,12 +442,11 @@ mod tests {
|
||||
DelegateAgentConfig {
|
||||
model: "test-model".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
let tool = DelegateTool::new(agents, test_security());
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"agent": "tester",
|
||||
@@ -493,12 +471,11 @@ mod tests {
|
||||
DelegateAgentConfig {
|
||||
model: "test-model".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
},
|
||||
);
|
||||
let tool = DelegateTool::new(agents, None, test_security());
|
||||
let tool = DelegateTool::new(agents, test_security());
|
||||
let result = tool
|
||||
.execute(json!({
|
||||
"agent": "tester",
|
||||
@@ -517,13 +494,13 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn delegate_depth_construction() {
|
||||
let tool = DelegateTool::with_depth(sample_agents(), None, test_security(), 5);
|
||||
let tool = DelegateTool::with_depth(sample_agents(), test_security(), 5);
|
||||
assert_eq!(tool.depth, 5);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delegate_no_agents_configured() {
|
||||
let tool = DelegateTool::new(HashMap::new(), None, test_security());
|
||||
let tool = DelegateTool::new(HashMap::new(), test_security());
|
||||
let result = tool
|
||||
.execute(json!({"agent": "any", "prompt": "test"}))
|
||||
.await
|
||||
|
||||
@@ -1,276 +1,78 @@
|
||||
use crate::openhuman::integrations::parallel::{SearchResponse, SearchResultItem};
|
||||
use crate::openhuman::integrations::IntegrationClient;
|
||||
use crate::openhuman::tools::traits::{Tool, ToolResult};
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde_json::json;
|
||||
use std::time::Duration;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Web search tool for searching the internet.
|
||||
/// Supports multiple providers: DuckDuckGo (free), Brave (requires API key), Parallel (requires API key).
|
||||
/// Web search tool backed by the server-side Parallel integration proxy.
|
||||
pub struct WebSearchTool {
|
||||
provider: String,
|
||||
brave_api_key: Option<String>,
|
||||
parallel_api_key: Option<String>,
|
||||
client: Option<Arc<IntegrationClient>>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
}
|
||||
|
||||
impl WebSearchTool {
|
||||
pub fn new(
|
||||
provider: String,
|
||||
brave_api_key: Option<String>,
|
||||
parallel_api_key: Option<String>,
|
||||
client: Option<Arc<IntegrationClient>>,
|
||||
max_results: usize,
|
||||
timeout_secs: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
provider: provider.trim().to_lowercase(),
|
||||
brave_api_key,
|
||||
parallel_api_key,
|
||||
client,
|
||||
max_results: max_results.clamp(1, 10),
|
||||
timeout_secs: timeout_secs.max(1),
|
||||
}
|
||||
}
|
||||
|
||||
async fn search_duckduckgo(&self, query: &str) -> anyhow::Result<String> {
|
||||
let encoded_query = urlencoding::encode(query);
|
||||
let search_url = format!("https://html.duckduckgo.com/html/?q={}", encoded_query);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
.user_agent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
.build()?;
|
||||
|
||||
let response = client.get(&search_url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!(
|
||||
"DuckDuckGo search failed with status: {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
|
||||
let html = response.text().await?;
|
||||
self.parse_duckduckgo_results(&html, query)
|
||||
}
|
||||
|
||||
fn parse_duckduckgo_results(&self, html: &str, query: &str) -> anyhow::Result<String> {
|
||||
// Extract result links: <a class="result__a" href="...">Title</a>
|
||||
let link_regex = Regex::new(
|
||||
r#"<a[^>]*class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)</a>"#,
|
||||
)?;
|
||||
|
||||
// Extract snippets: <a class="result__snippet">...</a>
|
||||
let snippet_regex = Regex::new(r#"<a class="result__snippet[^"]*"[^>]*>([\s\S]*?)</a>"#)?;
|
||||
|
||||
let link_matches: Vec<_> = link_regex
|
||||
.captures_iter(html)
|
||||
.take(self.max_results + 2)
|
||||
.collect();
|
||||
|
||||
let snippet_matches: Vec<_> = snippet_regex
|
||||
.captures_iter(html)
|
||||
.take(self.max_results + 2)
|
||||
.collect();
|
||||
|
||||
if link_matches.is_empty() {
|
||||
return Ok(format!("No results found for: {}", query));
|
||||
}
|
||||
|
||||
let mut lines = vec![format!("Search results for: {} (via DuckDuckGo)", query)];
|
||||
|
||||
let count = link_matches.len().min(self.max_results);
|
||||
|
||||
for i in 0..count {
|
||||
let caps = &link_matches[i];
|
||||
let url_str = decode_ddg_redirect_url(&caps[1]);
|
||||
let title = strip_tags(&caps[2]);
|
||||
|
||||
lines.push(format!("{}. {}", i + 1, title.trim()));
|
||||
lines.push(format!(" {}", url_str.trim()));
|
||||
|
||||
// Add snippet if available
|
||||
if i < snippet_matches.len() {
|
||||
let snippet = strip_tags(&snippet_matches[i][1]);
|
||||
let snippet = snippet.trim();
|
||||
if !snippet.is_empty() {
|
||||
lines.push(format!(" {}", snippet));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
async fn search_brave(&self, query: &str) -> anyhow::Result<String> {
|
||||
let api_key = self
|
||||
.brave_api_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Brave API key not configured"))?;
|
||||
|
||||
let encoded_query = urlencoding::encode(query);
|
||||
let search_url = format!(
|
||||
"https://api.search.brave.com/res/v1/web/search?q={}&count={}",
|
||||
encoded_query, self.max_results
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let response = client
|
||||
.get(&search_url)
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Subscription-Token", api_key)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("Brave search failed with status: {}", response.status());
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
self.parse_brave_results(&json, query)
|
||||
}
|
||||
|
||||
async fn search_parallel(&self, query: &str) -> anyhow::Result<String> {
|
||||
let api_key = self
|
||||
.parallel_api_key
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Parallel API key not configured"))?;
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(self.timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let body = json!({
|
||||
"objective": query,
|
||||
"search_queries": [query],
|
||||
"mode": "fast",
|
||||
"excerpts": {
|
||||
"max_chars_per_result": 10000
|
||||
}
|
||||
});
|
||||
|
||||
tracing::debug!(
|
||||
"[web_search] parallel: POST /v1beta/search for query={:?}",
|
||||
query
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post("https://api.parallel.ai/v1beta/search")
|
||||
.header("Content-Type", "application/json")
|
||||
.header("x-api-key", api_key)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
// Consume body without logging it — it may echo auth metadata.
|
||||
let _ = response.bytes().await;
|
||||
anyhow::bail!("Parallel search failed with status: {}", status);
|
||||
}
|
||||
|
||||
let json: serde_json::Value = response.json().await?;
|
||||
self.parse_parallel_results(&json, query)
|
||||
}
|
||||
|
||||
fn parse_parallel_results(
|
||||
&self,
|
||||
json: &serde_json::Value,
|
||||
results: &[SearchResultItem],
|
||||
query: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let results = json
|
||||
.get("results")
|
||||
.and_then(|r| r.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Parallel API response: missing 'results'"))?;
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("No results found for: {}", query));
|
||||
}
|
||||
|
||||
let mut lines = vec![format!("Search results for: {} (via Parallel)", query)];
|
||||
let mut lines = vec![format!(
|
||||
"Search results for: {} (via backend Parallel)",
|
||||
query
|
||||
)];
|
||||
|
||||
for (i, result) in results.iter().take(self.max_results).enumerate() {
|
||||
let title = result
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("No title");
|
||||
let url = result.get("url").and_then(|u| u.as_str()).unwrap_or("");
|
||||
let title = if result.title.trim().is_empty() {
|
||||
"No title"
|
||||
} else {
|
||||
result.title.trim()
|
||||
};
|
||||
let url = result.url.trim();
|
||||
|
||||
lines.push(format!("{}. {}", i + 1, title));
|
||||
lines.push(format!(" {}", url));
|
||||
|
||||
// Include the first excerpt (evidence-oriented text, LLM-ready).
|
||||
if let Some(excerpts) = result.get("excerpts").and_then(|e| e.as_array()) {
|
||||
if let Some(first) = excerpts.first().and_then(|e| e.as_str()) {
|
||||
let excerpt = first.trim();
|
||||
if !excerpt.is_empty() {
|
||||
// Truncate very long excerpts to keep tool output reasonable.
|
||||
let truncated = if excerpt.len() > 500 {
|
||||
format!("{}...", &excerpt[..500])
|
||||
} else {
|
||||
excerpt.to_string()
|
||||
};
|
||||
lines.push(format!(" {}", truncated));
|
||||
}
|
||||
if let Some(date) = result.publish_date.as_deref() {
|
||||
let date = date.trim();
|
||||
if !date.is_empty() {
|
||||
lines.push(format!(" Published: {}", date));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(first) = result.excerpts.first() {
|
||||
let excerpt = first.trim();
|
||||
if !excerpt.is_empty() {
|
||||
let truncated = if let Some((idx, _)) = excerpt.char_indices().nth(500) {
|
||||
format!("{}...", &excerpt[..idx])
|
||||
} else {
|
||||
excerpt.to_string()
|
||||
};
|
||||
lines.push(format!(" {}", truncated));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
fn parse_brave_results(&self, json: &serde_json::Value, query: &str) -> anyhow::Result<String> {
|
||||
let results = json
|
||||
.get("web")
|
||||
.and_then(|w| w.get("results"))
|
||||
.and_then(|r| r.as_array())
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid Brave API response"))?;
|
||||
|
||||
if results.is_empty() {
|
||||
return Ok(format!("No results found for: {}", query));
|
||||
}
|
||||
|
||||
let mut lines = vec![format!("Search results for: {} (via Brave)", query)];
|
||||
|
||||
for (i, result) in results.iter().take(self.max_results).enumerate() {
|
||||
let title = result
|
||||
.get("title")
|
||||
.and_then(|t| t.as_str())
|
||||
.unwrap_or("No title");
|
||||
let url = result.get("url").and_then(|u| u.as_str()).unwrap_or("");
|
||||
let description = result
|
||||
.get("description")
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
lines.push(format!("{}. {}", i + 1, title));
|
||||
lines.push(format!(" {}", url));
|
||||
if !description.is_empty() {
|
||||
lines.push(format!(" {}", description));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_ddg_redirect_url(raw_url: &str) -> String {
|
||||
if let Some(index) = raw_url.find("uddg=") {
|
||||
let encoded = &raw_url[index + 5..];
|
||||
let encoded = encoded.split('&').next().unwrap_or(encoded);
|
||||
if let Ok(decoded) = urlencoding::decode(encoded) {
|
||||
return decoded.into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
raw_url.to_string()
|
||||
}
|
||||
|
||||
fn strip_tags(content: &str) -> String {
|
||||
let re = Regex::new(r"<[^>]+>").unwrap();
|
||||
re.replace_all(content, "").to_string()
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -280,7 +82,7 @@ impl Tool for WebSearchTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search the web for information. Returns relevant search results with titles, URLs, and descriptions. Use this to find current information, news, or research topics."
|
||||
"Search the web for information via the backend search proxy. Returns relevant search results with titles, URLs, and descriptions."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -306,209 +108,229 @@ impl Tool for WebSearchTool {
|
||||
anyhow::bail!("Search query cannot be empty");
|
||||
}
|
||||
|
||||
tracing::info!("Searching web for: {}", query);
|
||||
let client = self.client.as_ref().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Web search unavailable: no backend session token. Sign in first so the server can proxy search."
|
||||
)
|
||||
})?;
|
||||
|
||||
let result = match self.provider.as_str() {
|
||||
"duckduckgo" | "ddg" => self.search_duckduckgo(query).await?,
|
||||
"brave" => self.search_brave(query).await?,
|
||||
"parallel" => self.search_parallel(query).await?,
|
||||
_ => anyhow::bail!("Unknown search provider: {}", self.provider),
|
||||
};
|
||||
let query_fingerprint = hex::encode(Sha256::digest(query.as_bytes()));
|
||||
tracing::debug!(
|
||||
query_len = query.chars().count(),
|
||||
query_fingerprint = %query_fingerprint[..16],
|
||||
max_results = self.max_results,
|
||||
timeout_secs = self.timeout_secs,
|
||||
"[web_search] backend parallel search"
|
||||
);
|
||||
|
||||
Ok(ToolResult::success(result))
|
||||
let body = json!({
|
||||
"objective": query,
|
||||
"searchQueries": [query],
|
||||
"mode": "fast",
|
||||
"excerpts": {
|
||||
"numResults": self.max_results,
|
||||
"maxCharactersPerExcerpt": 500
|
||||
},
|
||||
"timeoutSecs": self.timeout_secs
|
||||
});
|
||||
|
||||
let resp = client
|
||||
.post::<SearchResponse>("/agent-integrations/parallel/search", &body)
|
||||
.await?;
|
||||
|
||||
Ok(ToolResult::success(
|
||||
self.parse_parallel_results(&resp.results, query)?,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::{extract::State, routing::post, Json, Router};
|
||||
use serde_json::Value;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn ddg_tool() -> WebSearchTool {
|
||||
WebSearchTool::new("duckduckgo".to_string(), None, None, 5, 15)
|
||||
fn tool() -> WebSearchTool {
|
||||
WebSearchTool::new(None, 5, 15)
|
||||
}
|
||||
|
||||
async fn start_mock_backend(app: Router) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
format!("http://127.0.0.1:{}", addr.port())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_name() {
|
||||
assert_eq!(ddg_tool().name(), "web_search_tool");
|
||||
assert_eq!(tool().name(), "web_search_tool");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_description() {
|
||||
assert!(ddg_tool().description().contains("Search the web"));
|
||||
assert!(tool().description().contains("backend search proxy"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parameters_schema() {
|
||||
let schema = ddg_tool().parameters_schema();
|
||||
let schema = tool().parameters_schema();
|
||||
assert_eq!(schema["type"], "object");
|
||||
assert!(schema["properties"]["query"].is_object());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_tags() {
|
||||
let html = "<b>Hello</b> <i>World</i>";
|
||||
assert_eq!(strip_tags(html), "Hello World");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duckduckgo_results_empty() {
|
||||
let result = ddg_tool()
|
||||
.parse_duckduckgo_results("<html>No results here</html>", "test")
|
||||
.unwrap();
|
||||
assert!(result.contains("No results found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duckduckgo_results_with_data() {
|
||||
let html = r#"
|
||||
<a class="result__a" href="https://example.com">Example Title</a>
|
||||
<a class="result__snippet">This is a description</a>
|
||||
"#;
|
||||
let result = ddg_tool().parse_duckduckgo_results(html, "test").unwrap();
|
||||
assert!(result.contains("Example Title"));
|
||||
assert!(result.contains("https://example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duckduckgo_results_decodes_redirect_url() {
|
||||
let html = r#"
|
||||
<a class="result__a" href="https://duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com%2Fpath%3Fa%3D1&rut=test">Example Title</a>
|
||||
<a class="result__snippet">This is a description</a>
|
||||
"#;
|
||||
let result = ddg_tool().parse_duckduckgo_results(html, "test").unwrap();
|
||||
assert!(result.contains("https://example.com/path?a=1"));
|
||||
assert!(!result.contains("rut=test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_constructor_clamps_web_search_limits() {
|
||||
let tool = WebSearchTool::new("duckduckgo".to_string(), None, None, 0, 0);
|
||||
let html = r#"
|
||||
<a class="result__a" href="https://example.com">Example Title</a>
|
||||
<a class="result__snippet">This is a description</a>
|
||||
"#;
|
||||
let result = tool.parse_duckduckgo_results(html, "test").unwrap();
|
||||
assert!(result.contains("Example Title"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_missing_query() {
|
||||
let result = ddg_tool().execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_empty_query() {
|
||||
let result = ddg_tool().execute(json!({"query": ""})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_brave_without_api_key() {
|
||||
let tool = WebSearchTool::new("brave".to_string(), None, None, 5, 15);
|
||||
let result = tool.execute(json!({"query": "test"})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("API key"));
|
||||
}
|
||||
|
||||
// --- Parallel provider tests (mocked) ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_parallel_without_api_key() {
|
||||
let tool = WebSearchTool::new("parallel".to_string(), None, None, 5, 15);
|
||||
let result = tool.execute(json!({"query": "test"})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("API key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_parallel_results_empty() {
|
||||
let tool = WebSearchTool::new("parallel".to_string(), None, Some("key".to_string()), 5, 15);
|
||||
let json = serde_json::json!({ "results": [] });
|
||||
let result = tool.parse_parallel_results(&json, "test query").unwrap();
|
||||
let result = tool().parse_parallel_results(&[], "test query").unwrap();
|
||||
assert!(result.contains("No results found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_parallel_results_with_data() {
|
||||
let tool = WebSearchTool::new("parallel".to_string(), None, Some("key".to_string()), 5, 15);
|
||||
let json = serde_json::json!({
|
||||
"search_id": "abc-123",
|
||||
"results": [
|
||||
{
|
||||
"title": "Parallel AI Docs",
|
||||
"url": "https://docs.parallel.ai/home",
|
||||
"publish_date": null,
|
||||
"excerpts": ["Parallel provides infrastructure for AI web search."]
|
||||
},
|
||||
{
|
||||
"title": "Parallel Search Quickstart",
|
||||
"url": "https://docs.parallel.ai/search",
|
||||
"publish_date": "2024-01-01",
|
||||
"excerpts": ["Use POST /v1beta/search to retrieve results."]
|
||||
}
|
||||
],
|
||||
"warnings": null,
|
||||
"usage": [{ "name": "search", "count": 1 }]
|
||||
});
|
||||
let result = tool.parse_parallel_results(&json, "parallel ai").unwrap();
|
||||
assert!(result.contains("via Parallel"));
|
||||
let results = vec![
|
||||
SearchResultItem {
|
||||
title: "Parallel AI Docs".into(),
|
||||
url: "https://docs.parallel.ai/home".into(),
|
||||
publish_date: None,
|
||||
excerpts: vec!["Parallel provides infrastructure for AI web search.".into()],
|
||||
},
|
||||
SearchResultItem {
|
||||
title: "Parallel Search Quickstart".into(),
|
||||
url: "https://docs.parallel.ai/search".into(),
|
||||
publish_date: Some("2024-01-01".into()),
|
||||
excerpts: vec!["Use POST /v1beta/search to retrieve results.".into()],
|
||||
},
|
||||
];
|
||||
|
||||
let result = tool()
|
||||
.parse_parallel_results(&results, "parallel ai")
|
||||
.unwrap();
|
||||
assert!(result.contains("via backend Parallel"));
|
||||
assert!(result.contains("Parallel AI Docs"));
|
||||
assert!(result.contains("https://docs.parallel.ai/home"));
|
||||
assert!(result.contains("infrastructure for AI web search"));
|
||||
assert!(result.contains("Parallel Search Quickstart"));
|
||||
assert!(result.contains("Published: 2024-01-01"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_parallel_results_respects_max_results() {
|
||||
let tool = WebSearchTool::new(
|
||||
"parallel".to_string(),
|
||||
None,
|
||||
Some("key".to_string()),
|
||||
2, // max_results = 2
|
||||
15,
|
||||
);
|
||||
let json = serde_json::json!({
|
||||
"results": [
|
||||
{ "title": "Result 1", "url": "https://a.com", "excerpts": [] },
|
||||
{ "title": "Result 2", "url": "https://b.com", "excerpts": [] },
|
||||
{ "title": "Result 3", "url": "https://c.com", "excerpts": [] }
|
||||
]
|
||||
});
|
||||
let result = tool.parse_parallel_results(&json, "q").unwrap();
|
||||
let tool = WebSearchTool::new(None, 2, 15);
|
||||
let results = vec![
|
||||
SearchResultItem {
|
||||
title: "Result 1".into(),
|
||||
url: "https://a.com".into(),
|
||||
publish_date: None,
|
||||
excerpts: vec![],
|
||||
},
|
||||
SearchResultItem {
|
||||
title: "Result 2".into(),
|
||||
url: "https://b.com".into(),
|
||||
publish_date: None,
|
||||
excerpts: vec![],
|
||||
},
|
||||
SearchResultItem {
|
||||
title: "Result 3".into(),
|
||||
url: "https://c.com".into(),
|
||||
publish_date: None,
|
||||
excerpts: vec![],
|
||||
},
|
||||
];
|
||||
let result = tool.parse_parallel_results(&results, "q").unwrap();
|
||||
assert!(result.contains("Result 1"));
|
||||
assert!(result.contains("Result 2"));
|
||||
assert!(!result.contains("Result 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_parallel_results_missing_results_field() {
|
||||
let tool = WebSearchTool::new("parallel".to_string(), None, Some("key".to_string()), 5, 15);
|
||||
let json = serde_json::json!({ "search_id": "abc" });
|
||||
let result = tool.parse_parallel_results(&json, "q");
|
||||
fn test_parse_parallel_results_truncates_long_excerpt() {
|
||||
let long_excerpt = "x".repeat(600);
|
||||
let results = vec![SearchResultItem {
|
||||
title: "T".into(),
|
||||
url: "https://t.com".into(),
|
||||
publish_date: None,
|
||||
excerpts: vec![long_excerpt],
|
||||
}];
|
||||
let result = tool().parse_parallel_results(&results, "q").unwrap();
|
||||
assert!(result.contains("..."));
|
||||
let excerpt_line = result.lines().find(|l| l.trim().starts_with('x')).unwrap();
|
||||
assert!(excerpt_line.trim().len() <= 503);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_missing_query() {
|
||||
let result = tool().execute(json!({})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_empty_query() {
|
||||
let result = tool().execute(json!({"query": ""})).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_without_backend_client() {
|
||||
let result = tool().execute(json!({"query": "test"})).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("missing 'results'"));
|
||||
.contains("backend session token"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_parallel_results_truncates_long_excerpt() {
|
||||
let tool = WebSearchTool::new("parallel".to_string(), None, Some("key".to_string()), 5, 15);
|
||||
let long_excerpt = "x".repeat(600);
|
||||
let json = serde_json::json!({
|
||||
"results": [{
|
||||
"title": "T",
|
||||
"url": "https://t.com",
|
||||
"excerpts": [long_excerpt]
|
||||
}]
|
||||
});
|
||||
let result = tool.parse_parallel_results(&json, "q").unwrap();
|
||||
// Should contain truncated text ending with "..."
|
||||
assert!(result.contains("..."));
|
||||
// The excerpt portion in the output should not exceed 503 chars ("x"*500 + "...")
|
||||
let excerpt_line = result.lines().find(|l| l.trim().starts_with('x')).unwrap();
|
||||
assert!(excerpt_line.trim().len() <= 503);
|
||||
#[tokio::test]
|
||||
async fn test_execute_posts_to_backend_and_renders_results() {
|
||||
#[derive(Clone)]
|
||||
struct MockState {
|
||||
called: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
let state = MockState {
|
||||
called: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
let called = Arc::clone(&state.called);
|
||||
let app = Router::new()
|
||||
.route(
|
||||
"/agent-integrations/parallel/search",
|
||||
post(
|
||||
|State(state): State<MockState>, Json(body): Json<Value>| async move {
|
||||
state.called.store(true, Ordering::SeqCst);
|
||||
assert_eq!(body["objective"], "test success");
|
||||
assert_eq!(body["searchQueries"][0], "test success");
|
||||
Json(json!({
|
||||
"success": true,
|
||||
"data": {
|
||||
"searchId": "search-123",
|
||||
"results": [
|
||||
{
|
||||
"url": "https://example.com/result",
|
||||
"title": "Backend Search Result",
|
||||
"publish_date": "2026-04-20",
|
||||
"excerpts": ["Rendered excerpt from backend search."]
|
||||
}
|
||||
],
|
||||
"costUsd": 0.01
|
||||
}
|
||||
}))
|
||||
},
|
||||
),
|
||||
)
|
||||
.with_state(state);
|
||||
|
||||
let base_url = start_mock_backend(app).await;
|
||||
let client = Arc::new(IntegrationClient::new(base_url, "test-token".into()));
|
||||
let result = WebSearchTool::new(Some(client), 5, 15)
|
||||
.execute(json!({"query": "test success"}))
|
||||
.await
|
||||
.expect("execute() should return rendered backend results");
|
||||
|
||||
assert!(called.load(Ordering::SeqCst));
|
||||
assert!(result.output().contains("Backend Search Result"));
|
||||
assert!(result.output().contains("https://example.com/result"));
|
||||
assert!(result
|
||||
.output()
|
||||
.contains("Rendered excerpt from backend search."));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,7 +326,6 @@ mod tests {
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn shell_does_not_leak_api_key() {
|
||||
let _g1 = EnvGuard::set("API_KEY", "sk-test-secret-12345");
|
||||
let _g2 = EnvGuard::set("OPENHUMAN_API_KEY", "sk-test-secret-67890");
|
||||
|
||||
let tool = ShellTool::new(test_security_with_env_cmd(), test_runtime());
|
||||
let result = tool.execute(json!({"command": "env"})).await.unwrap();
|
||||
@@ -335,10 +334,6 @@ mod tests {
|
||||
!result.output().contains("sk-test-secret-12345"),
|
||||
"API_KEY leaked to shell command output"
|
||||
);
|
||||
assert!(
|
||||
!result.output().contains("sk-test-secret-67890"),
|
||||
"OPENHUMAN_API_KEY leaked to shell command output"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
+16
-83
@@ -25,19 +25,16 @@ pub fn default_tools_with_runtime(
|
||||
]
|
||||
}
|
||||
|
||||
/// Create full tool registry including memory tools and optional Composio
|
||||
/// Create full tool registry including memory tools.
|
||||
#[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
|
||||
pub fn all_tools(
|
||||
config: Arc<Config>,
|
||||
security: &Arc<SecurityPolicy>,
|
||||
memory: Arc<dyn Memory>,
|
||||
composio_key: Option<&str>,
|
||||
composio_entity_id: Option<&str>,
|
||||
browser_config: &crate::openhuman::config::BrowserConfig,
|
||||
http_config: &crate::openhuman::config::HttpRequestConfig,
|
||||
workspace_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
fallback_api_key: Option<&str>,
|
||||
root_config: &crate::openhuman::config::Config,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
all_tools_with_runtime(
|
||||
@@ -45,31 +42,25 @@ pub fn all_tools(
|
||||
security,
|
||||
Arc::new(NativeRuntime::new()),
|
||||
memory,
|
||||
composio_key,
|
||||
composio_entity_id,
|
||||
browser_config,
|
||||
http_config,
|
||||
workspace_dir,
|
||||
agents,
|
||||
fallback_api_key,
|
||||
root_config,
|
||||
)
|
||||
}
|
||||
|
||||
/// Create full tool registry including memory tools and optional Composio.
|
||||
/// Create full tool registry including memory tools.
|
||||
#[allow(clippy::implicit_hasher, clippy::too_many_arguments)]
|
||||
pub fn all_tools_with_runtime(
|
||||
config: Arc<Config>,
|
||||
security: &Arc<SecurityPolicy>,
|
||||
runtime: Arc<dyn RuntimeAdapter>,
|
||||
memory: Arc<dyn Memory>,
|
||||
composio_key: Option<&str>,
|
||||
composio_entity_id: Option<&str>,
|
||||
browser_config: &crate::openhuman::config::BrowserConfig,
|
||||
http_config: &crate::openhuman::config::HttpRequestConfig,
|
||||
workspace_dir: &std::path::Path,
|
||||
agents: &HashMap<String, DelegateAgentConfig>,
|
||||
fallback_api_key: Option<&str>,
|
||||
root_config: &crate::openhuman::config::Config,
|
||||
) -> Vec<Box<dyn Tool>> {
|
||||
// Build a session-scoped managed Node.js bootstrap once, so ShellTool,
|
||||
@@ -156,7 +147,7 @@ pub fn all_tools_with_runtime(
|
||||
browser_config.native_chrome_path.clone(),
|
||||
ComputerUseConfig {
|
||||
endpoint: browser_config.computer_use.endpoint.clone(),
|
||||
api_key: browser_config.computer_use.api_key.clone(),
|
||||
api_key: None,
|
||||
timeout_ms: browser_config.computer_use.timeout_ms,
|
||||
allow_remote_endpoint: browser_config.computer_use.allow_remote_endpoint,
|
||||
window_allowlist: browser_config.computer_use.window_allowlist.clone(),
|
||||
@@ -177,14 +168,12 @@ pub fn all_tools_with_runtime(
|
||||
http_config.timeout_secs,
|
||||
)));
|
||||
|
||||
// Web search — always registered. Provider / API-key / budget
|
||||
// Web search — always registered. Result/timeout budget
|
||||
// knobs still come from `config.web_search`, but there is no
|
||||
// enable flag: every session needs research as a baseline
|
||||
// capability.
|
||||
tools.push(Box::new(WebSearchTool::new(
|
||||
root_config.web_search.provider.clone(),
|
||||
root_config.web_search.brave_api_key.clone(),
|
||||
root_config.web_search.parallel_api_key.clone(),
|
||||
crate::openhuman::integrations::build_client(root_config),
|
||||
root_config.web_search.max_results,
|
||||
root_config.web_search.timeout_secs,
|
||||
)));
|
||||
@@ -217,16 +206,6 @@ pub fn all_tools_with_runtime(
|
||||
tracing::debug!("[computer] mouse and keyboard tools registered");
|
||||
}
|
||||
|
||||
if let Some(key) = composio_key {
|
||||
if !key.is_empty() {
|
||||
tools.push(Box::new(ComposioTool::new(
|
||||
key,
|
||||
composio_entity_id,
|
||||
security.clone(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Tool effectiveness stats (enabled when learning is on)
|
||||
tracing::debug!(
|
||||
learning_enabled = root_config.learning.enabled,
|
||||
@@ -244,13 +223,8 @@ pub fn all_tools_with_runtime(
|
||||
.iter()
|
||||
.map(|(name, cfg)| (name.clone(), cfg.clone()))
|
||||
.collect();
|
||||
let delegate_fallback_credential = fallback_api_key.and_then(|value| {
|
||||
let trimmed_value = value.trim();
|
||||
(!trimmed_value.is_empty()).then(|| trimmed_value.to_owned())
|
||||
});
|
||||
tools.push(Box::new(DelegateTool::new_with_options(
|
||||
delegate_agents,
|
||||
delegate_fallback_credential,
|
||||
security.clone(),
|
||||
crate::openhuman::providers::ProviderRuntimeOptions {
|
||||
auth_profile_override: None,
|
||||
@@ -335,13 +309,6 @@ pub fn all_tools_with_runtime(
|
||||
tools
|
||||
}
|
||||
|
||||
/// Hardware peripheral tools — always empty (boards removed); config kept for compatibility.
|
||||
pub async fn create_peripheral_tools(
|
||||
_config: &crate::openhuman::config::PeripheralsConfig,
|
||||
) -> anyhow::Result<Vec<Box<dyn Tool>>> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -376,7 +343,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig {
|
||||
enabled: false,
|
||||
@@ -391,13 +358,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -419,7 +383,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -429,13 +393,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -454,7 +415,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -464,13 +425,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -489,7 +447,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig {
|
||||
enabled: false,
|
||||
@@ -504,13 +462,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -529,7 +484,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig {
|
||||
enabled: true,
|
||||
@@ -544,13 +499,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -653,7 +605,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -665,7 +617,6 @@ mod tests {
|
||||
DelegateAgentConfig {
|
||||
model: "llama3".to_string(),
|
||||
system_prompt: None,
|
||||
api_key: None,
|
||||
temperature: None,
|
||||
max_depth: 3,
|
||||
},
|
||||
@@ -675,13 +626,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&agents,
|
||||
Some("delegate-test-credential"),
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -697,7 +645,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -707,13 +655,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -733,7 +678,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -743,13 +688,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -772,7 +714,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -783,13 +725,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -812,7 +751,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -823,13 +762,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
@@ -852,7 +788,7 @@ mod tests {
|
||||
..MemoryConfig::default()
|
||||
};
|
||||
let mem: Arc<dyn Memory> =
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path(), None).unwrap());
|
||||
Arc::from(crate::openhuman::memory::create_memory(&mem_cfg, tmp.path()).unwrap());
|
||||
|
||||
let browser = BrowserConfig::default();
|
||||
let http = crate::openhuman::config::HttpRequestConfig::default();
|
||||
@@ -863,13 +799,10 @@ mod tests {
|
||||
Arc::new(Config::default()),
|
||||
&security,
|
||||
mem,
|
||||
None,
|
||||
None,
|
||||
&browser,
|
||||
&http,
|
||||
tmp.path(),
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&cfg,
|
||||
);
|
||||
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
|
||||
|
||||
Reference in New Issue
Block a user