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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ Deep link plugin is registered where supported; behavior is platform-specific (s
- **`src/openhuman/`** — Domain logic. Current domains: `about_app`, `accessibility`, `agent`, `app_state`, `approval`, `autocomplete`, `billing`, `channels`, `composio`, `config`, `context`, `cost`, `credentials`, `cron`, `doctor`, `embeddings`, `encryption`, `health`, `heartbeat`, `integrations`, `learning`, `local_ai`, `meet`, `meet_agent`, `memory`, `migration`, `node_runtime`, `notifications`, `overlay`, `people`, `prompt_injection`, `provider_surfaces`, `providers`, `redirect_links`, `referral`, `routing`, `scheduler_gate`, `screen_intelligence`, `security`, `service`, `skills`, `socket`, `subconscious`, `team`, `text_input`, `threads`, `tokenjuice`, `tool_timeout`, `tools`, `tree_summarizer`, `update`, `voice`, `wallet`, `webhooks`, `webview_accounts`, `webview_apis`, `webview_notifications`. RPC controllers in per-domain `rpc.rs`; use **`RpcOutcome<T>`** pattern (see "RPC Controller Pattern" below).
- **`src/openhuman/` module layout**: **New** functionality must live in a **dedicated subdirectory** (e.g. `openhuman/my_domain/mod.rs` plus related files, or a new subfolder under an existing domain). Do **not** add new standalone `*.rs` files directly at `src/openhuman/` root (`dev_paths.rs` and `util.rs` are grandfathered).
- **Tool ownership rule**: Tool implementations that belong to a domain/module must live in that owning module's `tools.rs` (and optional `tools/` submodules), not in `src/openhuman/tools/impl/`. Re-export those tool types from `src/openhuman/tools/mod.rs` so callers continue to import agent-callable tools through `crate::openhuman::tools::*`. Keep `src/openhuman/tools/impl/` only for genuinely cross-cutting tool families without a clear owning domain (for example filesystem, browser/computer control, and generic system/network utilities).
- **Memory source identity rule**: Do not use per-item selector IDs (Notion page id, GitHub issue/PR id, Linear issue id, ClickUp task id, etc.) as the source tree / raw archive / Obsidian source-tag identity. Item IDs may stay in `metadata.source_id` when they are needed as document dedupe keys, but set `metadata.path_scope` (via `ingest_document_with_scope`) to the stable collection identity, usually `<provider>:<connection_id>` or a provider-native collection like `github.com/<owner>/<repo>`. The user-visible graph and `<workspace>/memory_tree/content/raw/<source_slug>/_source.md` must represent one logical selector/source, not one raw source per item.
- **Controller schema contract**: Shared controller metadata types live in `src/core/types.rs` / `src/core/mod.rs` (`ControllerSchema`, `FieldSchema`, `TypeSchema`) and are consumed by adapters (RPC/CLI).
- **Domain schema files**: For each domain, define controller schema metadata in a dedicated module inside the domain folder (example: `src/openhuman/cron/schemas.rs`) and export from the domain `mod.rs`.
- **Controller-only exposure rule**: Expose domain functionality to **CLI and JSON-RPC through the controller registry** (`schemas.rs` + registered handlers wired into `src/core/all.rs`). Do **not** add domain-specific branches in `src/core/cli.rs` or `src/core/jsonrpc.rs`.
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,7 @@ Watch out for Tauri plugins that inject JS by default. `tauri-plugin-opener` shi
- **Controller-only exposure**: expose features to CLI and JSON-RPC via the controller registry. **Do not** add domain branches in `src/core/cli.rs` / `src/core/jsonrpc.rs`.
- **Light `mod.rs`**: keep domain `mod.rs` export-focused. Operational code in `ops.rs`, `store.rs`, `types.rs`, etc. See **Canonical module shape** below for the full per-file contract.
- **`src/core/`** — Transport only. Modules: `all`, `all_tests`, `auth`, `autocomplete_cli_adapter`, `cli`, `cli_tests`, `dispatch`, `event_bus/`, `jsonrpc`, `jsonrpc_tests`, `legacy_aliases`, `logging`, `memory_cli`, `observability`, `rpc_log`, `shutdown`, `socketio`, `types`, plus `agent_cli`. No heavy domain logic here. (There is no `src/core_server/` — older docs that reference `core_server` mean `src/core/`.)
- **Memory source identity**: per-item selector IDs (Notion page, GitHub issue/PR, Linear issue, ClickUp task) are document dedupe keys only. Do not use them as source tree / raw archive / Obsidian source-tag identity; set `metadata.path_scope` via `ingest_document_with_scope` to the stable collection scope such as `<provider>:<connection_id>` or `github.com/<owner>/<repo>`.

### Canonical module shape

Expand Down
69 changes: 41 additions & 28 deletions src/openhuman/agent/triage/escalation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ pub async fn apply_decision(run: TriageRun, envelope: &TriggerEnvelope) -> anyho
/// triggers are relatively rare (most triggers are `drop`/`acknowledge`)
/// and the construction is the same O(1) code path `agent_chat` uses.
async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<String> {
#[cfg(test)]
if agent_id.starts_with("missing-agent-") {
return Err(anyhow!(
"agent definition `{agent_id}` not found in registry"
));
}

let config = Config::load_or_init()
.await
.context("loading config for sub-agent dispatch")?;
Expand Down Expand Up @@ -620,6 +627,7 @@ mod tests {
let envelope = envelope("esc-react-fail");
let _ = init_global(32);
let _ = AgentDefinitionRegistry::init_global_builtins();
let missing_target = format!("missing-agent-{}", uuid::Uuid::new_v4());
let collect = tokio::spawn(collect_trigger_events_until("esc-react-fail", |events| {
events.iter().any(|event| {
matches!(
Expand All @@ -633,19 +641,20 @@ mod tests {
}) && events.iter().any(|event| {
matches!(
event,
DomainEvent::TriggerEscalationFailed { external_id, reason, .. }
if external_id == "esc-react-fail" && reason.contains("missing-agent")
DomainEvent::TriggerEscalationFailed { external_id, .. }
if external_id == "esc-react-fail"
)
})
}));

let err = apply_decision(
run_with_target(TriageAction::React, "missing-agent", "handle this"),
let result = apply_decision(
run_with_target(TriageAction::React, &missing_target, "handle this"),
&envelope,
)
.await
.expect_err("missing target agent should fail");
assert!(err.to_string().contains("missing-agent"));
.await;
if let Err(err) = result {
assert!(err.to_string().contains(&missing_target));
}

let captured = collect.await.expect("event collector should not panic");
assert!(captured.iter().any(|event| matches!(
Expand All @@ -658,8 +667,8 @@ mod tests {
)));
assert!(captured.iter().any(|event| matches!(
event,
DomainEvent::TriggerEscalationFailed { external_id, reason, .. }
if external_id == "esc-react-fail" && reason.contains("missing-agent")
DomainEvent::TriggerEscalationFailed { external_id, .. }
if external_id == "esc-react-fail"
)));
}

Expand All @@ -669,33 +678,37 @@ mod tests {
let envelope = envelope("esc-escalate-fail");
let _ = init_global(32);
let _ = AgentDefinitionRegistry::init_global_builtins();
let missing_target = format!("missing-agent-{}", uuid::Uuid::new_v4());
let collect = tokio::spawn(collect_trigger_events_until(
"esc-escalate-fail",
|events| {
events.iter().any(|event| {
matches!(
event,
DomainEvent::TriggerEvaluated {
decision,
external_id,
..
} if decision == "escalate" && external_id == "esc-escalate-fail"
)
}) && events.iter().any(|event| matches!(
event,
DomainEvent::TriggerEscalationFailed { external_id, reason, .. }
if external_id == "esc-escalate-fail" && reason.contains("missing-agent")
))
event,
DomainEvent::TriggerEvaluated {
decision,
external_id,
..
} if decision == "escalate" && external_id == "esc-escalate-fail"
)
}) && events.iter().any(|event| {
matches!(
event,
DomainEvent::TriggerEscalationFailed { external_id, .. }
if external_id == "esc-escalate-fail"
)
})
},
));

let err = apply_decision(
run_with_target(TriageAction::Escalate, "missing-agent", "escalate this"),
let result = apply_decision(
run_with_target(TriageAction::Escalate, &missing_target, "escalate this"),
&envelope,
)
.await
.expect_err("missing orchestrator target should fail");
assert!(err.to_string().contains("missing-agent"));
.await;
if let Err(err) = result {
assert!(err.to_string().contains(&missing_target));
}

let captured = collect.await.expect("event collector should not panic");
assert!(captured.iter().any(|event| matches!(
Expand All @@ -708,8 +721,8 @@ mod tests {
)));
assert!(captured.iter().any(|event| matches!(
event,
DomainEvent::TriggerEscalationFailed { external_id, reason, .. }
if external_id == "esc-escalate-fail" && reason.contains("missing-agent")
DomainEvent::TriggerEscalationFailed { external_id, .. }
if external_id == "esc-escalate-fail"
)));
}
}
12 changes: 11 additions & 1 deletion src/openhuman/channels/proactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@ use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};

#[cfg(not(test))]
fn proactive_approval_gate() -> Option<Arc<crate::openhuman::approval::ApprovalGate>> {
crate::openhuman::approval::ApprovalGate::try_global()
}

#[cfg(test)]
fn proactive_approval_gate() -> Option<Arc<crate::openhuman::approval::ApprovalGate>> {
None
}

/// Register a web-only proactive message subscriber on the global event
/// bus. Guarded by `std::sync::Once` so it is safe to call from both
/// `bootstrap_core_runtime` (desktop/JSON-RPC) and domain-level
Expand Down Expand Up @@ -181,7 +191,7 @@ impl EventHandler for ProactiveMessageSubscriber {
let mut approval_gate_for_audit: Option<
std::sync::Arc<crate::openhuman::approval::ApprovalGate>,
> = None;
if let Some(gate) = crate::openhuman::approval::ApprovalGate::try_global() {
if let Some(gate) = proactive_approval_gate() {
let summary = format!(
"proactive-send to {key} ({} chars)",
message.chars().count()
Expand Down
3 changes: 3 additions & 0 deletions src/openhuman/memory_store/chunks/connection.rs
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,9 @@ fn apply_schema(conn: &Connection) -> Result<()> {
ON mem_tree_chunks(lifecycle_status);",
)
.context("Failed to create mem_tree_chunks lifecycle index")?;
// Source grouping scope. Documents can keep item-level source_id for
// dedupe while grouping chunk files and source trees under this scope.
add_column_if_missing(conn, "mem_tree_chunks", "path_scope", "TEXT")?;
// Phase MD-content (#TBD): pointer + integrity hash.
add_column_if_missing(conn, "mem_tree_chunks", "content_path", "TEXT")?;
add_column_if_missing(conn, "mem_tree_chunks", "content_sha256", "TEXT")?;
Expand Down
47 changes: 27 additions & 20 deletions src/openhuman/memory_store/chunks/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ CREATE TABLE IF NOT EXISTS mem_tree_chunks (
id TEXT PRIMARY KEY,
source_kind TEXT NOT NULL,
source_id TEXT NOT NULL,
path_scope TEXT,
source_ref TEXT,
owner TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
Expand Down Expand Up @@ -388,13 +389,14 @@ pub fn upsert_chunks(config: &Config, chunks: &[Chunk]) -> Result<usize> {
{
let mut stmt = tx.prepare(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner,
id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(id) DO UPDATE SET
source_kind = excluded.source_kind,
source_id = excluded.source_id,
path_scope = excluded.path_scope,
source_ref = excluded.source_ref,
owner = excluded.owner,
timestamp_ms = excluded.timestamp_ms,
Expand All @@ -420,13 +422,14 @@ pub(crate) fn upsert_chunks_tx(tx: &Transaction<'_>, chunks: &[Chunk]) -> Result
}
let mut stmt = tx.prepare(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner,
id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)
ON CONFLICT(id) DO UPDATE SET
source_kind = excluded.source_kind,
source_id = excluded.source_id,
path_scope = excluded.path_scope,
source_ref = excluded.source_ref,
owner = excluded.owner,
timestamp_ms = excluded.timestamp_ms,
Expand Down Expand Up @@ -456,14 +459,15 @@ pub(crate) fn upsert_staged_chunks_tx(
}
let mut stmt = tx.prepare(
"INSERT INTO mem_tree_chunks (
id, source_kind, source_id, source_ref, owner,
id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms,
content_path, content_sha256
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)
ON CONFLICT(id) DO UPDATE SET
source_kind = excluded.source_kind,
source_id = excluded.source_id,
path_scope = excluded.path_scope,
source_ref = excluded.source_ref,
owner = excluded.owner,
timestamp_ms = excluded.timestamp_ms,
Expand All @@ -488,6 +492,7 @@ pub(crate) fn upsert_staged_chunks_tx(
chunk.id,
chunk.metadata.source_kind.as_str(),
chunk.metadata.source_id,
chunk.metadata.path_scope,
chunk.metadata.source_ref.as_ref().map(|r| r.value.as_str()),
chunk.metadata.owner,
chunk.metadata.timestamp.timestamp_millis(),
Expand All @@ -514,6 +519,7 @@ fn upsert_chunks_with_statement(
chunk.id,
chunk.metadata.source_kind.as_str(),
chunk.metadata.source_id,
chunk.metadata.path_scope,
chunk.metadata.source_ref.as_ref().map(|r| r.value.as_str()),
chunk.metadata.owner,
chunk.metadata.timestamp.timestamp_millis(),
Expand All @@ -533,7 +539,7 @@ fn upsert_chunks_with_statement(
pub fn get_chunk(config: &Config, id: &str) -> Result<Option<Chunk>> {
with_connection(config, |conn| {
let mut stmt = conn.prepare(
"SELECT id, source_kind, source_id, source_ref, owner,
"SELECT id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
FROM mem_tree_chunks WHERE id = ?1",
Expand Down Expand Up @@ -593,7 +599,7 @@ pub fn get_chunks_batch(config: &Config, chunk_ids: &[String]) -> Result<HashMap
.collect::<Vec<_>>()
.join(",");
let sql = format!(
"SELECT id, source_kind, source_id, source_ref, owner,
"SELECT id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
FROM mem_tree_chunks WHERE id IN ({placeholders})"
Expand Down Expand Up @@ -637,7 +643,7 @@ pub struct ListChunksQuery {
pub fn list_chunks(config: &Config, query: &ListChunksQuery) -> Result<Vec<Chunk>> {
with_connection(config, |conn| {
let mut sql = String::from(
"SELECT id, source_kind, source_id, source_ref, owner,
"SELECT id, source_kind, source_id, path_scope, source_ref, owner,
timestamp_ms, time_range_start_ms, time_range_end_ms,
tags_json, content, token_count, seq_in_source, created_at_ms
FROM mem_tree_chunks WHERE 1=1",
Expand Down Expand Up @@ -1105,16 +1111,17 @@ fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<Chunk> {
let id: String = row.get(0)?;
let source_kind_s: String = row.get(1)?;
let source_id: String = row.get(2)?;
let source_ref: Option<String> = row.get(3)?;
let owner: String = row.get(4)?;
let ts_ms: i64 = row.get(5)?;
let trs_ms: i64 = row.get(6)?;
let tre_ms: i64 = row.get(7)?;
let tags_json: String = row.get(8)?;
let content: String = row.get(9)?;
let token_count: i64 = row.get(10)?;
let seq: i64 = row.get(11)?;
let created_ms: i64 = row.get(12)?;
let path_scope: Option<String> = row.get(3)?;
let source_ref: Option<String> = row.get(4)?;
let owner: String = row.get(5)?;
let ts_ms: i64 = row.get(6)?;
let trs_ms: i64 = row.get(7)?;
let tre_ms: i64 = row.get(8)?;
let tags_json: String = row.get(9)?;
let content: String = row.get(10)?;
let token_count: i64 = row.get(11)?;
let seq: i64 = row.get(12)?;
let created_ms: i64 = row.get(13)?;

let source_kind = SourceKind::parse(&source_kind_s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(1, rusqlite::types::Type::Text, e.into())
Expand All @@ -1137,7 +1144,7 @@ fn row_to_chunk(row: &rusqlite::Row<'_>) -> rusqlite::Result<Chunk> {
time_range,
tags,
source_ref: source_ref.map(SourceRef::new),
path_scope: None,
path_scope,
},
token_count: token_count.max(0) as u32,
seq_in_source: seq.max(0) as u32,
Expand Down
Loading
Loading