Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 63 additions & 4 deletions src/openhuman/memory/guard/families.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ use crate::openhuman::memory::api::provider::chunks::{
ChunkDetail, ChunkEmbedding, ChunkQuery, MemoryChunks,
};
use crate::openhuman::memory::api::provider::episodic::{
ConversationSegment, EpisodicTurn, MemoryEpisodic,
ConversationSegment, EpisodicEvent, EpisodicTurn, MemoryEpisodic,
};
use crate::openhuman::memory::api::provider::people::{
AddressBookSeedOutcome, MemoryPeople, PersonHandle, PersonInteraction, PersonRecord,
Expand Down Expand Up @@ -1080,6 +1080,27 @@ impl MemoryRetrieval for GuardedRetrieval {
.await
}

async fn recall_namespace_recent(
&self,
namespace: &str,
limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError> {
// `carries_content: false` like the scored sibling above, and for a
// stronger reason: the flag describes the payload the host *sends*, not
// the result it gets back (it only picks `check_egress`'s disclosure
// label). This call sends a namespace name and a count — no
// user-authored text at all — so there is also nothing to redact.
self.policy.admit_read(
Capability::Retrieval,
"retrieval.recall_namespace_recent",
namespace,
false,
)?;
self.family()?
.recall_namespace_recent(namespace, limit)
.await
}

async fn search_entities(
&self,
query: &str,
Expand Down Expand Up @@ -1113,6 +1134,39 @@ impl MemoryEpisodic for GuardedEpisodic {
self.family()?.insert_turn(turn).await
}

async fn insert_event(&self, event: &EpisodicEvent) -> Result<(), MemoryError> {
// Modelled on `tree.append`, not on `insert_turn` above. `insert_turn`
// passes `NO_NAMESPACE` because an `EpisodicTurn` has no namespace to
// admit against; an `EpisodicEvent` does, and admitting a write that
// names a namespace against none would scope it wider than the payload.
//
// `carries_content: true` and redaction for the same reason `tree.append`
// has both: the event's extracted prose is the outbound payload. Both
// are no-ops on the Embedded/Module/Null drivers shipping today and only
// bite on an External one — which is the case they exist for.
self.policy.admit_write(
Capability::Episodic,
"episodic.insert_event",
&event.namespace,
true,
)?;
// The trait takes `&EpisodicEvent`, so redaction needs an owned copy;
// `tree.append` takes its payload by value and avoids this.
let mut event = event.clone();
event.content = self.policy.redact_outbound(&event.content).into_owned();
event.subject = event
.subject
.take()
.map(|subject| self.policy.redact_outbound(&subject).into_owned());
trace_allowed(
&self.policy,
"episodic.insert_event",
&event.namespace,
event.content.chars().count(),
);
self.family()?.insert_event(&event).await
}

async fn session_turns(&self, session_id: &str) -> Result<Vec<EpisodicTurn>, MemoryError> {
self.policy.admit_read(
Capability::Episodic,
Expand Down Expand Up @@ -1142,11 +1196,14 @@ impl MemoryEpisodic for GuardedEpisodic {
session_id: &str,
namespace: &str,
start_episodic_id: i64,
start_seq: Option<u32>,
start_timestamp: f64,
now: f64,
) -> Result<(), MemoryError> {
// The only episodic call that names a namespace, so it is the only one
// that can be admitted against it.
// One of the two episodic calls that name a namespace, so it can be
// admitted against it. `insert_event` is the other; every remaining
// episodic method passes `NO_NAMESPACE` because its payload carries
// none.
self.policy.admit_write(
Capability::Episodic,
"episodic.create_segment",
Expand All @@ -1159,6 +1216,7 @@ impl MemoryEpisodic for GuardedEpisodic {
session_id,
namespace,
start_episodic_id,
start_seq,
start_timestamp,
now,
)
Expand All @@ -1169,6 +1227,7 @@ impl MemoryEpisodic for GuardedEpisodic {
&self,
segment_id: &str,
episodic_id: i64,
seq: Option<u32>,
timestamp: f64,
now: f64,
) -> Result<(), MemoryError> {
Expand All @@ -1179,7 +1238,7 @@ impl MemoryEpisodic for GuardedEpisodic {
false,
)?;
self.family()?
.append_turn(segment_id, episodic_id, timestamp, now)
.append_turn(segment_id, episodic_id, seq, timestamp, now)
.await
}

Expand Down
95 changes: 95 additions & 0 deletions src/openhuman/memory/guard/families_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,98 @@ async fn retrieve_leaves_intersects_an_explicit_scope_with_the_ambient_one() {
"an explicit scope outside the ambient one must fail closed"
);
}

// ── The two family members the v1.6.0 contract added ────────────────────────

#[tokio::test]
async fn recall_namespace_recent_is_admitted_as_a_read() {
let (driver, guard) = guarded(embedded_policy());
guard
.as_retrieval()
.unwrap()
.recall_namespace_recent("ns", 10)
.await
.expect("recall_namespace_recent");
assert_eq!(
driver.only_call().method,
"retrieval.recall_namespace_recent"
);
}

#[tokio::test]
async fn recall_namespace_recent_is_refused_for_an_untrusted_external_driver() {
// The guard has to decide before the driver is touched: a refusal that
// still reached the driver would have already disclosed the namespace.
let (driver, guard) = guarded(external_policy("untrusted"));
guard
.as_retrieval()
.unwrap()
.recall_namespace_recent("ns", 10)
.await
.expect_err("fail-closed");
assert_eq!(driver.call_count(), 0);
}

/// `insert_event` redacts, and `insert_turn` beside it does not.
///
/// This is the assertion the §2 design turns on: an `EpisodicEvent` carries
/// extracted prose *and* a namespace, so it follows `tree.append` rather than
/// `insert_turn`. `RecordingProvider` records the event content specifically so
/// a guard that skipped redaction shows up here rather than only in a live
/// store.
#[tokio::test]
async fn insert_event_redacts_its_content_on_an_external_driver() {
let secrety = "Authorization: Bearer abcdefghijklmnop";
let (driver, guard) = guarded(external_policy(
crate::openhuman::memory::guard::policy::TRUSTED,
));
guard
.as_episodic()
.unwrap()
.insert_event(&episodic_event("ns", secrety))
.await
.expect("insert_event");
let call = driver.only_call();
assert_eq!(call.method, "episodic.insert_event");
let content = call
.content
.as_deref()
.expect("the event content is recorded");
assert_ne!(
content, secrety,
"the credential reached the driver verbatim — `redact_outbound` was not applied"
);
}

#[tokio::test]
async fn insert_event_is_refused_for_an_untrusted_external_driver() {
let (driver, guard) = guarded(external_policy("untrusted"));
guard
.as_episodic()
.unwrap()
.insert_event(&episodic_event("ns", "anything"))
.await
.expect_err("fail-closed");
assert_eq!(driver.call_count(), 0);
}

fn episodic_event(
namespace: &str,
content: &str,
) -> crate::openhuman::memory::api::provider::episodic::EpisodicEvent {
use crate::openhuman::memory::api::provider::episodic::{EpisodicEvent, EventKind};
EpisodicEvent {
event_id: "e1".into(),
segment_id: "seg-1".into(),
session_id: "s1".into(),
namespace: namespace.into(),
kind: EventKind::Fact,
content: content.into(),
subject: Some(content.into()),
timestamp_ref: None,
confidence: 1.0,
embedding: None,
source_turn_ids: None,
created_at: 0.0,
}
}
27 changes: 27 additions & 0 deletions src/openhuman/memory/guard/test_support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,22 @@ impl MemoryEpisodic for RecordingProvider {
Ok(1)
}

async fn insert_event(
&self,
event: &crate::openhuman::memory::api::provider::episodic::EpisodicEvent,
) -> Result<(), MemoryError> {
// Records the event content, so a guard that failed to redact one would
// be visible here rather than only in a live store — same reason
// `insert_turn` above records its turn text.
self.record(Call {
method: "episodic.insert_event".into(),
content: Some(event.content.clone()),
taint: None,
scoped: None,
});
Ok(())
}

async fn session_turns(
&self,
_session_id: &str,
Expand All @@ -771,6 +787,7 @@ impl MemoryEpisodic for RecordingProvider {
_session_id: &str,
_namespace: &str,
_start_episodic_id: i64,
_start_seq: Option<u32>,
_start_timestamp: f64,
_now: f64,
) -> Result<(), MemoryError> {
Expand All @@ -782,6 +799,7 @@ impl MemoryEpisodic for RecordingProvider {
&self,
_segment_id: &str,
_episodic_id: i64,
_seq: Option<u32>,
_timestamp: f64,
_now: f64,
) -> Result<(), MemoryError> {
Expand Down Expand Up @@ -1012,6 +1030,15 @@ impl MemoryRetrieval for RecordingProvider {
Ok(vec![])
}

async fn recall_namespace_recent(
&self,
_namespace: &str,
_limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError> {
self.record(Call::plain("retrieval.recall_namespace_recent"));
Ok(vec![])
}

async fn search_entities(
&self,
_query: &str,
Expand Down
8 changes: 8 additions & 0 deletions src/openhuman/memory/preferences/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,14 @@ impl MemoryRetrieval for ScriptedRetrieval {
// `recall_namespace_scored` a scripted breakdown. They are unreachable, so
// they say so rather than returning a plausible empty value that could make
// a future test pass for the wrong reason.
async fn recall_namespace_recent(
&self,
_namespace: &str,
_limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError> {
unimplemented!("ScriptedRetrieval only serves recall_namespace_scored")
}

async fn fast_retrieve(
&self,
_query: &str,
Expand Down
3 changes: 3 additions & 0 deletions src/openhuman/memory/tools/raw_store/raw_chunks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ impl Tool for MemoryStoreRawChunksTool {
limit: parsed.limit,
offset: None,
exclude_dropped: false,
// New filter fields default to "no filter", preserving the
// behaviour this call site had before they existed.
..Default::default()
};
let guard = active_memory_guard()
.await
Expand Down
3 changes: 3 additions & 0 deletions src/openhuman/memory/tools/search/vector_search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ impl Tool for MemoryVectorSearchTool {
limit: Some(1000),
offset: None,
exclude_dropped: false,
// New filter fields default to "no filter", preserving the
// behaviour this call site had before they existed.
..Default::default()
};

let chunks = chunk_reader
Expand Down
3 changes: 3 additions & 0 deletions src/openhuman/memory/tree/tree/rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,9 @@ pub async fn list_chunks_rpc(
offset: None,
source_scope: None,
exclude_dropped: false,
// New filter fields default to "no filter", preserving the behaviour
// this call site had before they existed.
..Default::default()
};
let rows = tokio::task::spawn_blocking({
let config = config.clone();
Expand Down
32 changes: 32 additions & 0 deletions src/openhuman/modules/memory.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,6 +1144,38 @@ impl MemoryRetrieval for ModuleMemoryProvider {
(namespace, query, limit, exclude_session_id)
)
}
async fn recall_namespace_recent(
&self,
namespace: &str,
limit: usize,
) -> Result<Vec<NamespaceMemoryHit>, MemoryError> {
// The one method in this file that does not forward, because the pinned
// artifact has no `RecallNamespaceRecent` member to forward to — the
// contract crate gained this method after the release this host loads.
//
// `Ok(vec![])` would be the tempting answer and is the wrong one. An
// empty vector from this method *means something*: the contract says an
// unknown namespace yields one, "which is a true statement about it
// rather than a fault". Returning empty because the binary cannot serve
// the call makes this driver assert something false that no caller can
// tell apart from a genuinely empty namespace — the over-claim shape of
// #5641/#5623 moved from the capability list into a return value.
//
// Forwarding anyway would be honest but opaque: the wire name matches no
// arm in `from_wire`, so a caller gets an untyped `Other` after paying a
// round trip — the outcome this module's own docs name as the bad one
// (#5598). `Unsupported` is the contract's own vocabulary for exactly
// this, and callers already branch on it.
//
// This goes away on the next artifact re-pin, when the member exists and
// this collapses to a `module_call!` like every sibling.
let _ = (namespace, limit);
Err(MemoryError::unsupported_raw(format!(
Comment on lines +1172 to +1173

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the full-capability override before rejecting recent recall

When a developer loads the locally built v1.6 module with the documented OPENHUMAN_MEMORY_MODULE_ASSUME_FULL_CAPABILITIES=1 override, as_retrieval() advertises the full current contract, but this method still returns Unsupported without contacting that module. Consequently, the newly supported RecallNamespaceRecent member remains unusable and the advertised surface is internally inconsistent; return this pinned-artifact error only when the override is off, and forward the call when it is on.

Useful? React with 👍 / 👎.

"retrieval.recall_namespace_recent: not served by the pinned tinymemory \
module artifact {ARTIFACT_CAPABILITIES_PIN}"
)))
}

async fn search_entities(
&self,
query: &str,
Expand Down
Loading
Loading