fleet-memory/hindsight-cli/src/errors.rs
Nicolò Boschi 8d731f2e5f
feat: implement hierarchical configuration (system, tenant, bank) (#329)
* feat: implement hierarchical configuration (system, tenant, bank)

* feat: implement hierarchical configuration (system, tenant, bank)

* docs: add instructions for hierarchical config in CLAUDE.md

* feat: add ENABLE_BANK_CONFIG_API flag (disabled by default)

- Add HINDSIGHT_API_ENABLE_BANK_CONFIG_API env var (default: false)
- Return 403 Forbidden from bank config endpoints when disabled
- Update tests to enable the flag
- Update CLAUDE.md documentation

This provides security control over the bank configuration API,
ensuring it's only accessible when explicitly enabled.

* docs: add hierarchical configuration section

* feat(cli): add bank config commands (config, set-config, reset-config)

- Add 'hindsight bank config' to view bank configuration
- Add 'hindsight bank set-config' to update LLM settings per bank
- Add 'hindsight bank reset-config' to reset to defaults
- Implements client API calls to new bank config endpoints

* fix(cli): fix compilation errors in bank config commands

- Fix type signature: use ApiClient instead of api::Client
- Fix confirmation: use ui::prompt_confirmation instead of ui::confirm
- Fix error handling: use anyhow! macro instead of errors::Error
- Fix type conversion: convert HashMap to serde_json::Map for API call

* feat: implement type-safe hierarchical config with bank overrides

Implements a production-ready hierarchical configuration system that prevents
accidentally using global defaults when bank-specific overrides exist.

- Created StaticConfigProxy that wraps HindsightConfig
- get_config() now returns proxy that blocks access to bank-configurable fields
- Raises ConfigFieldAccessError with clear message when accessing configurable fields
- Added _get_raw_config() for internal use only
- Forces developers to use resolve_full_config(bank_id, context) for bank settings

- Added resolve_full_config() method that returns complete HindsightConfig
- Resolves hierarchy: Global (env) → Tenant → Bank
- No caching to support multi-server deployments (always fresh from DB)
- LLM provider pooling handles expensive operations separately

- Updated entire retain pipeline to pass resolved config through call chain
- memory_engine.py: Resolves config at top level where bank_id/context available
- orchestrator.py: Accepts and passes config to fact_extraction
- fact_extraction.py: Uses passed config instead of get_config()
- utils.py: Added optional config param for backward compatibility

- consolidator.py: Uses resolve_full_config() for enable_observations check
- memory_engine.py: Resolves config before triggering consolidation

- Renamed "Memory Bank" to "Bank Configuration" with tabs
- Combined Stats and Operations into "General" tab
- Consolidated Profile and Configuration into "Configuration" tab
- Moved Actions dropdown to page level (outside tabs)

- Created new component for managing bank-specific config
- Displays configurable fields: retain_chunk_size, retain_extraction_mode, etc.
- Edit via dialog with form validation
- Reset to defaults via AlertDialog confirmation
- Shows field IDs in monospace for clarity
- Visual separation with borders and hover effects

- Removed inline edit mode, switched to dialog-based editing
- Separate dialogs for Disposition and Mission editing
- Read-only display with clear edit buttons
- Removed duplicate stats cards and operations

- bank-stats-view.tsx: Overview statistics (memories, links, documents, pending ops)
- bank-operations-view.tsx: Background operations table with filtering

**Problem**: Consolidation always used global enable_observations, ignoring bank overrides
**Root Cause**: consolidator.py called get_config() instead of resolving bank-specific config
**Solution**: Pass resolved config through the entire pipeline

**Problem**: asyncpg returning JSONB as JSON string instead of parsed dict
**Solution**: Explicit JSON parsing in config_resolver.py with type checking

- All 19 API integration tests pass
- All 10 hierarchical config tests pass
- Retain operations work correctly with bank-specific config
- Consolidation respects bank-specific enable_observations setting

- Updated developer/configuration.md with type-safe config access pattern
- Added examples showing correct usage patterns
- Documented ConfigFieldAccessError and resolution methods

- get_config() now returns StaticConfigProxy (blocks configurable field access)
- Code accessing bank-configurable fields must use resolve_full_config()
- Clear migration path with helpful error messages

Fixes hierarchical configuration to be production-ready with proper type safety.

* refactor: remove LLM client pool and simplify config resolver

Since LLM config (provider, model, api_key) is now static and not
bank-configurable, the LLMClientPool is no longer needed.

Changes:
- Remove hindsight_api/llm_client_pool.py (no longer needed)
- Remove memory_engine._get_bank_llm_config() (dead code, never called)
- Simplify config_resolver.py by eliminating duplication between
  resolve_full_config() and get_bank_config()
- get_bank_config() now calls resolve_full_config() and filters results
- Remove outdated "LLM provider pooling" comments from docstrings

All tests pass (10 hierarchical config tests, 19 API integration tests)

* fix: update tests to use _get_raw_config() for configurable fields

Fixed test fixtures that were accessing configurable fields (like
enable_observations) from get_config(), which now raises
ConfigFieldAccessError due to type-safe config access.

Changes:
- test_consolidation.py: Changed enable_observations fixture to use
  _get_raw_config() instead of get_config()
- test_consolidation.py: Updated test_consolidation_returns_disabled_status
  to set bank config instead of mocking get_config()
- test_link_expansion_retrieval.py: Changed fixture to use _get_raw_config()
- test_observations.py: Changed disable_observations fixture to use
  _get_raw_config()
- Regenerated OpenAPI spec and clients

All 39 previously failing tests now pass.

* fix: add missing config parameter to test calls of extract_facts_from_text()

Fixed 45 test failures where tests were calling extract_facts_from_text()
without the new required config parameter.

Changes:
- Added config=_get_raw_config() to all extract_facts_from_text() calls
- Fixed test_main_module.py to patch _get_raw_config instead of get_config
- Updated 6 test files with 37 function call sites

All tests should now pass.

* fix: add missing config parameter to test_skip_podcast_meta_commentary

One more test was missing the config parameter for extract_facts_from_text().
2026-02-12 13:14:57 +01:00

215 lines
9.1 KiB
Rust

use colored::*;
pub fn handle_api_error(err: anyhow::Error, api_url: &str) -> ! {
eprintln!("{}", format_error_message(&err, api_url));
std::process::exit(1);
}
fn format_error_message(err: &anyhow::Error, api_url: &str) -> String {
let err_str = err.to_string();
// Connection refused
if err_str.contains("Connection refused") || err_str.contains("tcp connect error") || err_str.contains("error sending request") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n{}\n\n{}\n {}",
"".bright_red().bold(),
"Cannot connect to Hindsight API".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The Hindsight API server is not running".bright_white(),
format!("The server is running on a different address than {}", api_url).bright_white(),
"A firewall is blocking the connection".bright_white(),
"Try:".bright_green(),
"Start the Hindsight API server and ensure it's accessible".bright_white()
);
}
// Timeout
if err_str.contains("timeout") || err_str.contains("Timeout") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n{}\n{}",
"".bright_red().bold(),
"Request timed out".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API server is slow to respond".bright_white(),
"Network latency is too high".bright_white(),
"Try:".bright_green(),
"Check if the API server is healthy".bright_white(),
"Try again with a better network connection".bright_white()
);
}
// DNS/Host resolution
if err_str.contains("dns") || err_str.contains("DNS") || err_str.contains("failed to lookup") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n {}",
"".bright_red().bold(),
"Cannot resolve API hostname".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The hostname in the API URL is incorrect".bright_white(),
"DNS server is not responding".bright_white(),
"Try:".bright_green(),
"Check the HINDSIGHT_API_URL environment variable".bright_white()
);
}
// 404 Not Found - check for disabled features first
if err_str.contains("404") {
if err_str.contains("Bank configuration API is disabled") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}",
"".bright_red().bold(),
"Bank configuration API is disabled".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"This feature is disabled by default for security.".bright_yellow(),
"To enable, set HINDSIGHT_API_ENABLE_BANK_CONFIG_API=true on the API server".bright_white(),
"Note:".bright_cyan(),
"This allows per-bank LLM configuration overrides via API".bright_white()
);
}
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n {}",
"".bright_red().bold(),
"API endpoint not found (404)".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API endpoint path has changed".bright_white(),
"You're using an incompatible API version".bright_white(),
"Try:".bright_green(),
"Check that you're using the correct Hindsight API version".bright_white()
);
}
// 401 Authentication failed
if err_str.contains("401") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n {}",
"".bright_red().bold(),
"Authentication failed".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"API requires authentication".bright_white(),
"Invalid or missing credentials".bright_white(),
"Try:".bright_green(),
"Check if the API requires an API key or token".bright_white()
);
}
// 403 Forbidden
if err_str.contains("403") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n {}",
"".bright_red().bold(),
"Permission denied (403)".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"This operation is not allowed".bright_white(),
"The feature may be disabled on the server".bright_white(),
"Try:".bright_green(),
"Check server configuration or contact your administrator".bright_white()
);
}
// 500 Server Error
if err_str.contains("500") || err_str.contains("502") || err_str.contains("503") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n\n{}\n{}\n{}",
"".bright_red().bold(),
"API server error".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"The server encountered an error:".bright_yellow(),
"Internal server error (500)".bright_white(),
"Service temporarily unavailable".bright_white(),
"Try:".bright_green(),
"Check the API server logs for details".bright_white(),
"Try again in a few moments".bright_white()
);
}
// Invalid URL
if err_str.contains("invalid URL") || err_str.contains("InvalidUri") {
return format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}",
"".bright_red().bold(),
"Invalid API URL".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"The API URL format is invalid.".bright_yellow(),
"Ensure it starts with http:// or https://".bright_white(),
"Example:".bright_green(),
"export HINDSIGHT_API_URL=http://localhost:8888".bright_white()
);
}
// JSON parsing error - show actual response
if err_str.contains("Failed to parse") || err_str.contains("error decoding") {
// Extract the actual response if available
let response_hint = if err_str.contains("Response was:") {
let parts: Vec<&str> = err_str.split("Response was:").collect();
if parts.len() > 1 {
format!("\n{}\n{}", "Actual response:".bright_yellow(), parts[1].trim().bright_white())
} else {
String::new()
}
} else {
String::new()
};
return format!(
"{} {}\n\n{}\n {}\n\n{}\n{}\n{}\n{}{}\n\n{}\n{}\n{}",
"".bright_red().bold(),
"Invalid API response format".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Possible causes:".bright_yellow(),
"The API returned an unexpected response format".bright_white(),
"Version mismatch between CLI and API".bright_white(),
"The API endpoint doesn't exist or returned HTML instead of JSON".bright_white(),
response_hint,
"Try:".bright_green(),
"Run with --verbose flag to see the full request/response".bright_white(),
"Ensure you're using a compatible Hindsight API version".bright_white()
);
}
// Generic error with the full error message
format!(
"{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n{}\n{}\n{}",
"".bright_red().bold(),
"API request failed".bright_red().bold(),
"API URL:".bright_yellow(),
api_url.bright_white(),
"Error:".bright_yellow(),
err_str.bright_white(),
"Suggestions:".bright_green(),
"Check that HINDSIGHT_API_URL is set correctly".bright_white(),
"Ensure the Hindsight API server is running".bright_white(),
"Verify network connectivity to the API server".bright_white()
)
}
pub fn print_config_help() {
println!("\n{}", "Configuration:".bright_cyan().bold());
println!(" Run the configure command to set the API URL:");
println!(" {}", "hindsight configure".bright_white());
println!();
println!(" Or set it directly:");
println!(" {}", "hindsight configure --api-url http://your-api:8888".bright_white());
println!();
println!(" {}", "Configuration priority:".bright_yellow());
println!(" 1. Environment variable (HINDSIGHT_API_URL) - highest priority");
println!(" 2. Config file (~/.hindsight/config)");
println!(" 3. Default (http://localhost:8888)");
println!();
}