Skip to content

Commit e8b1def

Browse files
authored
feat(agent): add agent_id for entity/journal attribution + context filtering (#176)
Roadmap Phase 2, Week 4-6: Agent Identity (v1.2.0). Every entity and journal event now carries an agent_id, enabling: - Attribution: which agent wrote this? - Filtering: context injection scoped to a single agent - Audit: journal timeline shows who did what Changes: - schema.rs: agent_id TEXT DEFAULT '' on entities + journal tables; zero-downtime ALTER migrations for both - models.rs: agent_id field on Entity, JournalEvent; agent_id: Option<String> filter on RecallParams - db.rs: remember() INSERT/UPDATE persist agent_id; journal INSERT includes agent_id; fts5_search applies equality filter when params.agent_id is Some; entity_from_row reads col 22; journal timeline readers updated for col 8; ALL 6 recall SELECT lists include trailing agent_id column (was missed on first pass — caused empty agent_id on recall until fixed) - tools.rs: agent_id on RememberArgs, RecallArgs, JournalArgs - mcp.rs: agent_id in mimir_remember, mimir_recall, mimir_journal inputSchemas Tests: agent_id_filters_in_recall, agent_id_roundtrips, journal_agent_attribution. 31 passing (0 failed, 1 ignored). E2E verified through live MCP: hal-9000 recall returns only its own entity with correct agent_id; journal records agent attribution (jrn-fe3c02dcae79).
1 parent 1f84de7 commit e8b1def

5 files changed

Lines changed: 159 additions & 17 deletions

File tree

src/db.rs

Lines changed: 111 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,7 @@ impl Database {
253253
verified: false,
254254
source: format!("connector:{}", name),
255255
workspace_hash: String::new(),
256+
agent_id: String::new(),
256257
created_at_unix_ms: now,
257258
last_accessed_unix_ms: now,
258259
embedding: None,
@@ -571,7 +572,7 @@ impl Database {
571572
decay_score, retrieval_count, layer, topic_path,
572573
archived, archive_reason, links, verified, source,
573574
created_at_unix_ms, last_accessed_unix_ms, embedding,
574-
always_on, certainty, workspace_hash
575+
always_on, certainty, workspace_hash, agent_id
575576
FROM entities WHERE archived = 0 AND embedding IS NOT NULL LIMIT {}",
576577
max_scan
577578
))?;
@@ -854,9 +855,9 @@ impl Database {
854855
decay_score = ?5, layer = ?6, topic_path = ?7,
855856
archived = ?8, archive_reason = ?9, links = ?10,
856857
verified = ?11, source = ?12, last_accessed_unix_ms = ?13,
857-
always_on = ?14, certainty = ?15, workspace_hash = ?16,
858+
always_on = ?14, certainty = ?15, workspace_hash = ?16, agent_id = ?17,
858859
retrieval_count = retrieval_count + 1
859-
WHERE id = ?17",
860+
WHERE id = ?18",
860861
params![
861862
body_encrypted,
862863
entity.status,
@@ -874,6 +875,7 @@ impl Database {
874875
entity.always_on as i32,
875876
entity.certainty,
876877
entity.workspace_hash,
878+
entity.agent_id,
877879
id,
878880
],
879881
)?;
@@ -914,11 +916,11 @@ impl Database {
914916
decay_score, retrieval_count, layer, topic_path,
915917
archived, archive_reason, links, verified, source,
916918
always_on, certainty, created_at_unix_ms, last_accessed_unix_ms,
917-
workspace_hash)
919+
workspace_hash, agent_id)
918920
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7,
919921
?8, ?9, ?10, ?11,
920922
?12, ?13, ?14, ?15, ?16,
921-
?17, ?18, ?19, ?20, ?21)",
923+
?17, ?18, ?19, ?20, ?21, ?22)",
922924
params![
923925
id,
924926
entity.category,
@@ -941,6 +943,7 @@ impl Database {
941943
entity.created_at_unix_ms,
942944
entity.last_accessed_unix_ms,
943945
entity.workspace_hash,
946+
entity.agent_id,
944947
],
945948
)?;
946949

@@ -1104,6 +1107,13 @@ impl Database {
11041107
param_values.push(Box::new(ws.clone()));
11051108
}
11061109

1110+
// Filter by agent_id (v1.2.0 attribution). When set, only entities
1111+
// written by the specified agent are visible.
1112+
if let Some(ref aid) = params.agent_id {
1113+
conditions.push(format!("agent_id = ?{}", param_values.len() + 1));
1114+
param_values.push(Box::new(aid.clone()));
1115+
}
1116+
11071117
// Exclude archived unless explicitly requested
11081118
if !params.include_archived {
11091119
conditions.push("archived = 0".to_string());
@@ -1115,7 +1125,7 @@ impl Database {
11151125
decay_score, retrieval_count, layer, topic_path,
11161126
archived, archive_reason, links, verified, source,
11171127
created_at_unix_ms, last_accessed_unix_ms, embedding,
1118-
always_on, certainty, workspace_hash
1128+
always_on, certainty, workspace_hash, agent_id
11191129
FROM entities",
11201130
);
11211131

@@ -1284,7 +1294,7 @@ impl Database {
12841294
decay_score, retrieval_count, layer, topic_path,
12851295
archived, archive_reason, links, verified, source,
12861296
created_at_unix_ms, last_accessed_unix_ms, embedding,
1287-
always_on, certainty, workspace_hash
1297+
always_on, certainty, workspace_hash, agent_id
12881298
FROM entities WHERE category = ?1 AND key = ?2 LIMIT 1",
12891299
)?;
12901300

@@ -1415,8 +1425,8 @@ impl Database {
14151425
self.conn.execute(
14161426
"INSERT INTO journal
14171427
(id, event_type, evaluated_json, acted_json, forward_json,
1418-
category, key, entity_id, created_at_unix_ms)
1419-
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1428+
category, key, entity_id, agent_id, created_at_unix_ms)
1429+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
14201430
params![
14211431
event.id,
14221432
event.event_type,
@@ -1426,6 +1436,7 @@ impl Database {
14261436
event.category,
14271437
event.key,
14281438
event.entity_id,
1439+
event.agent_id,
14291440
event.created_at_unix_ms,
14301441
],
14311442
)?;
@@ -1473,7 +1484,7 @@ impl Database {
14731484

14741485
let mut sql = String::from(
14751486
"SELECT id, event_type, evaluated_json, acted_json, forward_json,
1476-
category, key, entity_id, created_at_unix_ms
1487+
category, key, entity_id, agent_id, created_at_unix_ms
14771488
FROM journal",
14781489
);
14791490

@@ -1508,7 +1519,8 @@ impl Database {
15081519
category: row.get(5)?,
15091520
key: row.get(6)?,
15101521
entity_id: row.get(7)?,
1511-
created_at_unix_ms: row.get(8)?,
1522+
agent_id: row.get::<_, Option<String>>(8).unwrap_or(None).unwrap_or_default(),
1523+
created_at_unix_ms: row.get(9)?,
15121524
})
15131525
})?;
15141526

@@ -1777,7 +1789,7 @@ impl Database {
17771789
decay_score, retrieval_count, layer, topic_path,
17781790
archived, archive_reason, links, verified, source,
17791791
created_at_unix_ms, last_accessed_unix_ms, embedding,
1780-
always_on, certainty, workspace_hash
1792+
always_on, certainty, workspace_hash, agent_id
17811793
FROM entities WHERE id = ?1",
17821794
)?;
17831795
let mut rows = stmt.query_map(params![id], |row| {
@@ -1811,7 +1823,7 @@ impl Database {
18111823
decay_score, retrieval_count, layer, topic_path,
18121824
archived, archive_reason, links, verified, source,
18131825
created_at_unix_ms, last_accessed_unix_ms, embedding,
1814-
always_on, certainty, workspace_hash
1826+
always_on, certainty, workspace_hash, agent_id
18151827
FROM entities WHERE archived = 0",
18161828
);
18171829
let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
@@ -1858,7 +1870,7 @@ impl Database {
18581870
) -> Result<Vec<JournalEvent>, Box<dyn std::error::Error>> {
18591871
let mut stmt = self.conn.prepare(
18601872
"SELECT id, event_type, evaluated_json, acted_json, forward_json,
1861-
category, key, entity_id, created_at_unix_ms
1873+
category, key, entity_id, agent_id, created_at_unix_ms
18621874
FROM journal ORDER BY created_at_unix_ms DESC LIMIT ?1",
18631875
)?;
18641876
let rows = stmt.query_map(params![limit], |row| {
@@ -1871,7 +1883,8 @@ impl Database {
18711883
category: row.get::<_, String>(5).unwrap_or_default(),
18721884
key: row.get::<_, String>(6).unwrap_or_default(),
18731885
entity_id: row.get::<_, String>(7).unwrap_or_default(),
1874-
created_at_unix_ms: row.get(8)?,
1886+
agent_id: row.get::<_, Option<String>>(8).unwrap_or(None).unwrap_or_default(),
1887+
created_at_unix_ms: row.get(9)?,
18751888
})
18761889
})?;
18771890
let mut items = Vec::new();
@@ -2326,6 +2339,7 @@ last_accessed: {}
23262339
always_on: false,
23272340
certainty: 0.5,
23282341
workspace_hash: String::new(),
2342+
agent_id: String::new(),
23292343
created_at_unix_ms: now_ms(),
23302344
last_accessed_unix_ms: now_ms(),
23312345
embedding: None,
@@ -2478,7 +2492,7 @@ last_accessed: {}
24782492
decay_score, retrieval_count, layer, topic_path,
24792493
archived, archive_reason, links, verified, source,
24802494
created_at_unix_ms, last_accessed_unix_ms, embedding,
2481-
always_on, certainty, workspace_hash
2495+
always_on, certainty, workspace_hash, agent_id
24822496
FROM entities
24832497
WHERE archived = 0 AND ({})
24842498
ORDER BY decay_score DESC, retrieval_count DESC
@@ -2745,6 +2759,7 @@ fn entity_from_row(
27452759
always_on: row.get::<_, i32>(19).unwrap_or(0) != 0,
27462760
certainty: row.get::<_, f64>(20).unwrap_or(0.5),
27472761
workspace_hash: row.get::<_, Option<String>>(21).unwrap_or(None).unwrap_or_default(),
2762+
agent_id: row.get::<_, Option<String>>(22).unwrap_or(None).unwrap_or_default(),
27482763
created_at_unix_ms: row.get(16)?,
27492764
last_accessed_unix_ms: row.get(17)?,
27502765
embedding: None,
@@ -2785,6 +2800,7 @@ mod tests {
27852800
always_on: false,
27862801
certainty: 0.5,
27872802
workspace_hash: String::new(),
2803+
agent_id: String::new(),
27882804
created_at_unix_ms: now_ms(),
27892805
last_accessed_unix_ms: now_ms(),
27902806
embedding: None,
@@ -3007,6 +3023,7 @@ mod tests {
30073023
category: "decision".to_string(),
30083024
key: "use-pg".to_string(),
30093025
entity_id: "e1".to_string(),
3026+
agent_id: "agent-1".to_string(),
30103027
created_at_unix_ms: now_ms(),
30113028
};
30123029
db.journal(&event).unwrap();
@@ -3325,6 +3342,7 @@ mod tests {
33253342
diversity_halving: 1.0,
33263343
diversity_per_query_share: 0.0,
33273344
workspace_hash: None,
3345+
agent_id: None,
33283346
},
33293347
)
33303348
.unwrap();
@@ -3451,6 +3469,7 @@ mod tests {
34513469
diversity_halving: 1.0f64,
34523470
diversity_per_query_share: 0.0f64,
34533471
workspace_hash: None,
3472+
agent_id: None,
34543473
}) {
34553474
Ok(_) => {},
34563475
Err(e) => {
@@ -3509,6 +3528,7 @@ mod tests {
35093528
diversity_halving: 1.0,
35103529
diversity_per_query_share: 0.0,
35113530
workspace_hash: ws,
3531+
agent_id: None,
35123532
};
35133533

35143534
// Scope to "alpha" — should only see ent_a
@@ -3551,6 +3571,7 @@ mod tests {
35513571
mode: crate::models::SearchMode::Fts5, embedding: None, preview_cap: None,
35523572
always_on: None, content_weight: 0.0, diversity_halving: 1.0,
35533573
diversity_per_query_share: 0.0, workspace_hash: None,
3574+
agent_id: None,
35543575
};
35553576
let results = db.recall(&params).unwrap();
35563577
let found = results.iter().find(|e| e.key == "key1").expect("entity recalled");
@@ -3559,4 +3580,78 @@ mod tests {
35593580
assert_eq!(found.certainty, 0.9, "certainty must roundtrip");
35603581
}
35613582

3583+
3584+
#[test]
3585+
fn agent_id_filters_in_recall() {
3586+
// Phase 2 Week 4-6: entities tagged with agent_id are filterable.
3587+
let (db, _path) = temp_db();
3588+
3589+
let mut ent_a = make_entity("aid-a", "agents", "agent-a-key", r#"{"content":"alpha agent xyzzy unique data"}"#);
3590+
ent_a.agent_id = "squad-leader".to_string();
3591+
db.remember(&ent_a).unwrap();
3592+
3593+
let mut ent_b = make_entity("aid-b", "agents", "agent-b-key", r#"{"content":"beta agent plugh distinct info"}"#);
3594+
ent_b.agent_id = "scout".to_string();
3595+
db.remember(&ent_b).unwrap();
3596+
3597+
// No filter — sees both
3598+
let all = db.recall(&crate::models::RecallParams {
3599+
query: "agent".to_string(), agent_id: None,
3600+
..crate::models::RecallParams::default()
3601+
}).unwrap();
3602+
let all_keys: Vec<_> = all.iter().map(|e| e.key.as_str()).collect();
3603+
assert!(all_keys.contains(&"agent-a-key"));
3604+
assert!(all_keys.contains(&"agent-b-key"));
3605+
3606+
// Filter by "squad-leader" — only sees ent_a
3607+
let squad = db.recall(&crate::models::RecallParams {
3608+
query: "agent".to_string(), agent_id: Some("squad-leader".to_string()),
3609+
..crate::models::RecallParams::default()
3610+
}).unwrap();
3611+
let squad_keys: Vec<_> = squad.iter().map(|e| e.key.as_str()).collect();
3612+
assert!(squad_keys.contains(&"agent-a-key"));
3613+
assert!(!squad_keys.contains(&"agent-b-key"));
3614+
assert_eq!(squad.len(), 1);
3615+
}
3616+
3617+
#[test]
3618+
fn agent_id_roundtrips() {
3619+
let (db, _path) = temp_db();
3620+
let mut ent = make_entity("rt-aid", "agents", "k", r#"{"content":"roundtrip"}"#);
3621+
ent.workspace_hash = "scope".to_string();
3622+
ent.agent_id = "secret-agent-man".to_string();
3623+
db.remember(&ent).unwrap();
3624+
3625+
let results = db.recall(&crate::models::RecallParams {
3626+
query: "roundtrip".to_string(),
3627+
..crate::models::RecallParams::default()
3628+
}).unwrap();
3629+
let found = results.iter().find(|e| e.key == "k").unwrap();
3630+
assert_eq!(found.agent_id, "secret-agent-man");
3631+
assert_eq!(found.workspace_hash, "scope");
3632+
}
3633+
3634+
#[test]
3635+
fn journal_agent_attribution() {
3636+
let (db, _path) = temp_db();
3637+
let event = crate::models::JournalEvent {
3638+
id: "jrn-agent-1".to_string(),
3639+
event_type: "test".to_string(),
3640+
evaluated_json: "{}".to_string(),
3641+
acted_json: "{}".to_string(),
3642+
forward_json: "{}".to_string(),
3643+
category: "test".to_string(),
3644+
key: "t1".to_string(),
3645+
entity_id: String::new(),
3646+
agent_id: "security-bot".to_string(),
3647+
created_at_unix_ms: now_ms(),
3648+
};
3649+
db.journal(&event).unwrap();
3650+
3651+
let events = db.timeline(&crate::models::TimelineParams::default()).unwrap();
3652+
assert!(!events.is_empty());
3653+
let found = events.iter().find(|e| e.id == "jrn-agent-1").unwrap();
3654+
assert_eq!(found.agent_id, "security-bot");
3655+
}
3656+
35623657
}

src/mcp.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,11 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
252252
"type": "string",
253253
"default": "",
254254
"description": "Workspace scope identifier (v1.2.0). Empty = global. Entities with a workspace_hash are invisible to recall queries scoped to a different workspace."
255+
},
256+
"agent_id": {
257+
"type": "string",
258+
"default": "",
259+
"description": "Agent identity (v1.2.0). Tracks which agent wrote this entity. Used for agent attribution and context filtering."
255260
}
256261
},
257262
"required": [
@@ -370,6 +375,10 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
370375
"workspace_hash": {
371376
"type": "string",
372377
"description": "Workspace scope filter (v1.2.0). When set, only entities with a matching workspace_hash are returned. Omit for no workspace filtering."
378+
},
379+
"agent_id": {
380+
"type": "string",
381+
"description": "Agent identity filter (v1.2.0). When set, only entities with a matching agent_id are returned. Omit for no agent filtering."
373382
}
374383
},
375384
"required": [
@@ -746,6 +755,11 @@ fn list_tools(id: Option<Value>) -> JsonRpcResponse {
746755
"entity_id": {
747756
"type": "string",
748757
"description": "Related entity ID for linking"
758+
},
759+
"agent_id": {
760+
"type": "string",
761+
"default": "",
762+
"description": "Agent identity (v1.2.0). Records which agent created this journal event."
749763
}
750764
},
751765
"required": []

src/models.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ pub struct Entity {
4242
/// Entities are invisible across workspaces when a scope is set.
4343
#[serde(default)]
4444
pub workspace_hash: String,
45+
/// Agent identity (v1.2.0). Tracks which agent wrote this entity.
46+
/// Used for agent attribution and context filtering.
47+
#[serde(default)]
48+
pub agent_id: String,
4549
pub created_at_unix_ms: i64,
4650
pub last_accessed_unix_ms: i64,
4751
#[serde(skip)]
@@ -125,6 +129,7 @@ pub struct JournalEvent {
125129
pub key: String,
126130
#[serde(default)]
127131
pub entity_id: String,
132+
pub agent_id: String,
128133
pub created_at_unix_ms: i64,
129134
}
130135

@@ -172,6 +177,9 @@ pub struct RecallParams {
172177
/// Workspace scope filter (v1.2.0). When Some, only entities with a
173178
/// matching workspace_hash are returned. None = no workspace filtering.
174179
pub workspace_hash: Option<String>,
180+
/// Agent identity filter (v1.2.0). When Some, only entities with a
181+
/// matching agent_id are returned. None = no agent filtering.
182+
pub agent_id: Option<String>,
175183
}
176184

177185
/// Search mode for recall: FTS5 keyword, dense vector, or hybrid fusion.
@@ -231,6 +239,7 @@ impl Default for RecallParams {
231239
diversity_halving: 1.0,
232240
diversity_per_query_share: 0.0,
233241
workspace_hash: None,
242+
agent_id: None,
234243
}
235244
}
236245
}

0 commit comments

Comments
 (0)