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
82 changes: 70 additions & 12 deletions apps/elf-api/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,18 +52,19 @@ use elf_service::{
CoreBlockUpsertRequest, CoreBlockUpsertResponse, CoreBlocksGetRequest, CoreBlocksResponse,
DeleteRequest, DeleteResponse, DocType, DocsExcerptResponse, DocsExcerptsGetRequest,
DocsGetRequest, DocsGetResponse, DocsPutRequest, DocsPutResponse, DocsSearchL0Request,
DocsSearchL0Response, Error, EventMessage, GranteeKind, GraphQueryEntityRef,
GraphQueryPredicateRef, GraphQueryRequest, GraphQueryResponse, IngestionProfileSelector,
KnowledgePageGetRequest, KnowledgePageLintRequest, KnowledgePageLintResponse,
KnowledgePageRebuildRequest, KnowledgePageRebuildResponse, KnowledgePageResponse,
KnowledgePageSearchRequest, KnowledgePageSearchResponse, KnowledgePagesListRequest,
KnowledgePagesListResponse, ListRequest, ListResponse, MemoryHistoryGetRequest,
MemoryHistoryResponse, NoteFetchRequest, NoteFetchResponse, NoteProvenanceBundleResponse,
NoteProvenanceGetRequest, PayloadLevel, PublishNoteRequest, QueryPlan, RankingRequestOverride,
RebuildReport, SearchDetailsRequest, SearchDetailsResult, SearchExplainRequest,
SearchExplainResponse, SearchIndexItem, SearchRequest, SearchResponse, SearchSessionGetRequest,
SearchTimelineGroup, SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary,
ShareScope, SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest,
DocsSearchL0Response, EntityMemoryViewRequest, EntityMemoryViewResponse, Error, EventMessage,
GranteeKind, GraphQueryEntityRef, GraphQueryPredicateRef, GraphQueryRequest,
GraphQueryResponse, IngestionProfileSelector, KnowledgePageGetRequest,
KnowledgePageLintRequest, KnowledgePageLintResponse, KnowledgePageRebuildRequest,
KnowledgePageRebuildResponse, KnowledgePageResponse, KnowledgePageSearchRequest,
KnowledgePageSearchResponse, KnowledgePagesListRequest, KnowledgePagesListResponse,
ListRequest, ListResponse, MemoryHistoryGetRequest, MemoryHistoryResponse, NoteFetchRequest,
NoteFetchResponse, NoteProvenanceBundleResponse, NoteProvenanceGetRequest, PayloadLevel,
PublishNoteRequest, QueryPlan, RankingRequestOverride, RebuildReport, SearchDetailsRequest,
SearchDetailsResult, SearchExplainRequest, SearchExplainResponse, SearchIndexItem,
SearchRequest, SearchResponse, SearchSessionGetRequest, SearchTimelineGroup,
SearchTimelineRequest, SearchTrajectoryResponse, SearchTrajectorySummary, ShareScope,
SpaceGrantRevokeRequest, SpaceGrantRevokeResponse, SpaceGrantUpsertRequest,
SpaceGrantsListRequest, TextPositionSelector, TextQuoteSelector, TraceBundleGetRequest,
TraceBundleResponse, TraceGetRequest, TraceGetResponse, TraceRecentListRequest,
TraceRecentListResponse, TraceTrajectoryGetRequest, UnpublishNoteRequest, UpdateRequest,
Expand Down Expand Up @@ -115,6 +116,7 @@ const VIEWER_HTML: &str = include_str!("../static/viewer.html");
docs_search_l0,
docs_excerpts_get,
core_blocks_get,
entity_memory_get,
admin_core_block_upsert,
admin_core_block_attach,
admin_core_block_detach,
Expand Down Expand Up @@ -630,6 +632,12 @@ enum SearchMode {
PlannedSearch,
}

#[derive(Clone, Debug, Deserialize)]
struct EntityMemoryQuery {
entity_id: Option<Uuid>,
entity_surface: Option<String>,
}

/// Builds the authenticated public API router.
pub fn router(state: AppState) -> Router {
let auth_state = state.clone();
Expand All @@ -638,6 +646,7 @@ pub fn router(state: AppState) -> Router {
.route("/v2/notes/ingest", routing::post(notes_ingest))
.route("/v2/events/ingest", routing::post(events_ingest))
.route("/v2/core-blocks", routing::get(core_blocks_get))
.route("/v2/entity-memory", routing::get(entity_memory_get))
.route("/v2/searches", routing::post(searches_create))
.route("/v2/searches/{search_id}", routing::get(searches_get))
.route("/v2/searches/{search_id}/timeline", routing::get(searches_timeline))
Expand Down Expand Up @@ -1426,6 +1435,55 @@ async fn core_blocks_get(
Ok(Json(response))
}

#[utoipa::path(
get,
path = "/v2/entity-memory",
tag = "graph",
params(
("entity_id" = Option<Uuid>, Query, description = "Graph entity id. Exactly one of entity_id or entity_surface is required."),
("entity_surface" = Option<String>, Query, description = "Canonical or alias entity surface. Exactly one of entity_id or entity_surface is required."),
),
responses(
(status = 200, description = "Entity-scoped memory authority view.", body = Value),
(status = 400, description = "Invalid request.", body = ErrorBody),
(status = 401, description = "Authentication required.", body = ErrorBody),
(status = 403, description = "Scope denied.", body = ErrorBody),
(status = 404, description = "Entity was not found.", body = ErrorBody),
(status = 500, description = "Internal error.", body = ErrorBody),
)
)]
async fn entity_memory_get(
State(state): State<AppState>,
headers: HeaderMap,
query: Result<Query<EntityMemoryQuery>, QueryRejection>,
) -> Result<Json<EntityMemoryViewResponse>, ApiError> {
let ctx = RequestContext::from_headers(&headers)?;
let read_profile = required_read_profile(&headers)?;
let Query(query) = query.map_err(|err| {
tracing::warn!(error = %err, "Invalid query parameters.");

ApiError::new(
StatusCode::BAD_REQUEST,
"INVALID_REQUEST",
"Invalid query parameters.".to_string(),
None,
)
})?;
let response = state
.service
.entity_memory_view(EntityMemoryViewRequest {
tenant_id: ctx.tenant_id,
project_id: ctx.project_id,
agent_id: ctx.agent_id,
read_profile,
entity_id: query.entity_id,
entity_surface: query.entity_surface,
})
.await?;

Ok(Json(response))
}

#[utoipa::path(
post,
path = "/v2/admin/core-blocks",
Expand Down
1 change: 1 addition & 0 deletions apps/elf-api/tests/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -945,6 +945,7 @@ async fn openapi_json_route_serves_generated_contract() {
assert_openapi_method(&spec, "/v2/notes/ingest", "post");
assert_openapi_method(&spec, "/v2/events/ingest", "post");
assert_openapi_method(&spec, "/v2/core-blocks", "get");
assert_openapi_method(&spec, "/v2/entity-memory", "get");
assert_openapi_method(&spec, "/v2/docs/search/l0", "post");
assert_openapi_method(&spec, "/v2/searches/{search_id}/notes", "post");
assert_openapi_method(&spec, "/v2/admin/core-blocks", "post");
Expand Down
71 changes: 71 additions & 0 deletions apps/elf-eval/tests/real_world_job_benchmark.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6398,6 +6398,77 @@ fn core_archival_memory_fixtures_score_separate_core_and_archival_jobs() -> Resu
Ok(())
}

#[test]
fn memory_authority_benchmark_covers_entity_history_and_core_archive_strengths() -> Result<()> {
let report = run_json_report_from(real_world_memory_fixture_dir())?;

assert_eq!(
report.pointer("/summary/history_readback_encoded_count").and_then(Value::as_u64),
Some(1)
);

let suites = array_at(&report, "/suites")?;
let memory_evolution = find_by_field(suites, "/suite_id", "memory_evolution")?;
let core_archival = find_by_field(suites, "/suite_id", "core_archival_memory")?;

assert_eq!(memory_evolution.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(core_archival.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(
memory_evolution.pointer("/history_readback_encoded_count").and_then(Value::as_u64),
Some(1)
);
assert_eq!(core_archival.pointer("/encoded_job_count").and_then(Value::as_u64), Some(6));

let jobs = array_at(&report, "/jobs")?;
let preference = find_by_field(jobs, "/job_id", "memory-evolution-preference-001")?;
let core_attachment =
find_by_field(jobs, "/job_id", "core-archival-core-block-attachment-001")?;
let archival_fallback = find_by_field(jobs, "/job_id", "core-archival-archival-fallback-001")?;

assert_eq!(preference.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(
preference.pointer("/evolution/history_readback_encoded").and_then(Value::as_bool),
Some(true)
);
assert!(array_contains_str(preference, "/evolution/history_event_types", "update")?);
assert_eq!(core_attachment.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(archival_fallback.pointer("/status").and_then(Value::as_str), Some("pass"));

let adapters = array_at(&report, "/external_adapters/adapters")?;
let mem0 = find_by_field(adapters, "/adapter_id", "mem0_openmemory_live_baseline")?;
let letta = find_by_field(adapters, "/adapter_id", "letta_research_gate")?;
let mem0_scenarios = array_at(mem0, "/scenarios")?;
let mem0_history =
find_by_field(mem0_scenarios, "/scenario_id", "preference_correction_history")?;
let mem0_entity =
find_by_field(mem0_scenarios, "/scenario_id", "entity_scoped_personalization")?;

assert_eq!(mem0_history.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(mem0_entity.pointer("/status").and_then(Value::as_str), Some("pass"));
assert_eq!(mem0_history.pointer("/comparison_outcome").and_then(Value::as_str), Some("loss"));
assert_eq!(mem0_entity.pointer("/comparison_outcome").and_then(Value::as_str), Some("tie"));

let letta_scenarios = array_at(letta, "/scenarios")?;
let letta_core =
find_by_field(letta_scenarios, "/scenario_id", "core_block_attachment_readback")?;
let letta_fallback =
find_by_field(letta_scenarios, "/scenario_id", "archival_fallback_readback")?;

for scenario in [letta_core, letta_fallback] {
assert_eq!(
scenario.pointer("/suite_id").and_then(Value::as_str),
Some("core_archival_memory")
);
assert_eq!(scenario.pointer("/status").and_then(Value::as_str), Some("blocked"));
assert_eq!(
scenario.pointer("/comparison_outcome").and_then(Value::as_str),
Some("blocked")
);
}

Ok(())
}

#[test]
fn context_trajectory_fixtures_report_blocked_openviking_gates() -> Result<()> {
let report = run_json_report_from(context_trajectory_fixture_dir())?;
Expand Down
36 changes: 35 additions & 1 deletion apps/elf-mcp/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,21 @@ impl ElfMcp {
self.forward(HttpMethod::Get, "/v2/core-blocks", params, None).await
}

#[rmcp::tool(
name = "elf_entity_memory_get",
description = "Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.",
input_schema = entity_memory_get_schema()
)]
async fn elf_entity_memory_get(
&self,
mut params: JsonObject,
) -> Result<CallToolResult, ErrorData> {
// read_profile is part of the MCP server configuration and is not client-controlled.
let _ = take_optional_string(&mut params, "read_profile")?;

self.forward(HttpMethod::Get, "/v2/entity-memory", params, None).await
}

#[rmcp::tool(
name = "elf_searches_create",
description = "Create a search session using quick-find or planned-search mode. Response includes optional trajectory_summary for staged retrieval progress.",
Expand Down Expand Up @@ -1197,6 +1212,18 @@ fn core_blocks_get_schema() -> Arc<JsonObject> {
}))
}

fn entity_memory_get_schema() -> Arc<JsonObject> {
Arc::new(rmcp::object!({
"type": "object",
"additionalProperties": true,
"properties": {
"entity_id": { "type": ["string", "null"], "format": "uuid" },
"entity_surface": { "type": ["string", "null"] },
"read_profile": { "type": ["string", "null"] }
}
}))
}

fn searches_create_schema() -> Arc<JsonObject> {
let filter_schema = rmcp::object!({
"type": "object",
Expand Down Expand Up @@ -1576,7 +1603,7 @@ mod tests {

type RequestRecorder = Arc<Mutex<Option<oneshot::Sender<RecordedRequest>>>>;

const ALL_TOOL_DEFINITIONS: [ToolDefinition; 30] = [
const ALL_TOOL_DEFINITIONS: [ToolDefinition; 31] = [
ToolDefinition::new(
"elf_notes_ingest",
HttpMethod::Post,
Expand Down Expand Up @@ -1607,6 +1634,12 @@ mod tests {
"/v2/core-blocks",
"Fetch core memory blocks explicitly attached to the configured agent and read profile.",
),
ToolDefinition::new(
"elf_entity_memory_get",
HttpMethod::Get,
"/v2/entity-memory",
"Fetch an entity-scoped memory view across attached core blocks and graph-linked archival notes.",
),
ToolDefinition::new(
"elf_searches_get",
HttpMethod::Get,
Expand Down Expand Up @@ -1797,6 +1830,7 @@ mod tests {
"elf_graph_query",
"elf_events_ingest",
"elf_core_blocks_get",
"elf_entity_memory_get",
"elf_searches_create",
"elf_searches_get",
"elf_searches_timeline",
Expand Down
80 changes: 80 additions & 0 deletions docs/spec/system_elf_memory_service_v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -1949,6 +1949,85 @@ Notes:
tenant/project/agent/read_profile and the block is readable under that read_profile's
scopes and shared grants.

GET /v2/entity-memory

Headers:
- X-ELF-Tenant-Id (required)
- X-ELF-Project-Id (required)
- X-ELF-Agent-Id (required)
- X-ELF-Read-Profile (required)

Query:
- entity_id: uuid, optional.
- entity_surface: string, optional canonical or alias surface.
- Exactly one of entity_id or entity_surface is required.

Response:
{
"schema": "elf.entity_memory_view/v1",
"tenant_id": "string",
"project_id": "string",
"agent_id": "requesting-agent",
"read_profile": "private_only|private_plus_project|all_scopes",
"as_of": "...",
"entity": {
"entity_id": "uuid",
"canonical": "Alice",
"kind": "person|null",
"surfaces": ["Alice", "Alicia"]
},
"summary": {
"current_count": 0,
"stale_count": 0,
"superseded_count": 0,
"tombstoned_count": 0,
"top_of_mind_count": 0,
"background_count": 0,
"core_block_count": 0,
"archival_note_count": 0
},
"items": [
{
"source": "core_block|archival_note",
"lifecycle": "current|stale|superseded|tombstoned",
"read_bucket": "top_of_mind|background",
"scope": "agent_private|project_shared|org_shared",
"agent_id": "source-owner-agent",
"note_id": "uuid|null",
"block_id": "uuid|null",
"attachment_id": "uuid|null",
"note_type": "string|null",
"key": "string|null",
"title": "string|null",
"text": "string",
"importance": 0.0,
"confidence": 0.0,
"source_ref": { ... },
"updated_at": "...",
"expires_at": "...|null",
"relations": [
{
"fact_id": "uuid",
"predicate": "prefers",
"scope": "agent_private|project_shared|org_shared",
"actor": "fact-owner-agent",
"valid_from": "...",
"valid_to": "...|null",
"temporal_status": "current|historical|future"
}
]
}
]
}

Behavior:
- The endpoint resolves a graph entity by id or canonical/alias surface within the request tenant/project.
- It returns graph evidence notes linked through `graph_facts` and `graph_fact_evidence`, including stale, superseded, and tombstoned lifecycle buckets for authority readback.
- It also returns attached core blocks for the exact tenant/project/agent/read_profile when block key/title/content/source_ref mentions the canonical entity or one of its aliases.
- Read access is still governed by read_profile scopes and shared grants. `agent_private` rows are visible only to their owning agent.
- Core blocks are classified as `current` and `top_of_mind`; archival notes are `top_of_mind` only when they are current and importance is at least 0.8.
- This endpoint is read-only. It does not embed, rerank, mutate notes or blocks, create search sessions, write Qdrant points, or record note hits.

POST /v2/searches

Headers:
Expand Down Expand Up @@ -2283,6 +2362,7 @@ Original query:
- elf_notes_ingest -> POST /v2/notes/ingest
- elf_events_ingest -> POST /v2/events/ingest
- elf_core_blocks_get -> GET /v2/core-blocks
- elf_entity_memory_get -> GET /v2/entity-memory
- elf_graph_query -> POST /v2/graph/query
- elf_searches_create -> POST /v2/searches
- elf_searches_get -> GET /v2/searches/{search_id}
Expand Down
8 changes: 8 additions & 0 deletions docs/spec/system_version_registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ This document is normative. When a new versioned identifier is introduced, it mu
- Consumers: Admin tooling and MCP adapter (`elf_admin_memory_history_get`), diagnostics runbooks, lifecycle benchmarks.
- Bump rule: Introduce a new history version only when event shape or ordering semantics become incompatible with v1 clients.

### Entity memory view schema

- Identifier: `elf.entity_memory_view/v1`.
- Type: Entity-scoped readback envelope that joins attached core memory blocks with graph-linked archival notes.
- Defined in: `docs/spec/system_elf_memory_service_v2.md`.
- Consumers: HTTP API (`GET /v2/entity-memory`), MCP adapter (`elf_entity_memory_get`), memory-authority benchmarks.
- Bump rule: Introduce a new view version when item lifecycle/read-bucket semantics, relation shape, or required response keys become incompatible with v1 clients.

### Doc Extension v1 docs filters contract

- Identifier: `docs_search_filters/v1`.
Expand Down
Loading