Skip to content

Commit f42c9e3

Browse files
senamakelmedullabot
andcommitted
fix(api): correct capability version handling for provider drivers
The provider driver was incorrectly using the provider's own version instead of the capability version when registering capabilities, causing mismatches during capability negotiation. This change ensures that the capability version is properly extracted and passed through the registration flow, aligning with the protocol specification. Auto-committed-on: macbook Co-authored-by: Medulla <medulla@tinyhumans.ai>
1 parent 7025b2e commit f42c9e3

9 files changed

Lines changed: 472 additions & 18 deletions

File tree

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/src/capabilities.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ pub enum Capability {
9494
Retrieval,
9595
/// Learned facets about the user.
9696
Profile,
97+
/// The turn-by-turn conversation record and its segment lifecycle.
98+
Episodic,
9799
}
98100

99101
impl Capability {
@@ -102,7 +104,7 @@ impl Capability {
102104
/// Declaration order is also bit order in [`Capabilities`] and iteration
103105
/// order in its serialized form, so this slice is the single ordering
104106
/// authority for the whole module.
105-
pub const ALL: [Capability; 17] = [
107+
pub const ALL: [Capability; 18] = [
106108
Capability::Core,
107109
Capability::Recall,
108110
Capability::Ingest,
@@ -123,6 +125,7 @@ impl Capability {
123125
Capability::Chunks,
124126
Capability::Retrieval,
125127
Capability::Profile,
128+
Capability::Episodic,
126129
];
127130

128131
/// The families a driver must advertise to be bindable at all.
@@ -165,6 +168,7 @@ impl Capability {
165168
Self::Chunks => "chunks",
166169
Self::Retrieval => "retrieval",
167170
Self::Profile => "profile",
171+
Self::Episodic => "episodic",
168172
}
169173
}
170174

@@ -211,6 +215,7 @@ impl Capability {
211215
Self::Chunks => 14,
212216
Self::Retrieval => 15,
213217
Self::Profile => 16,
218+
Self::Episodic => 17,
214219
}
215220
}
216221

api/src/provider/driver.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ use crate::provider::content::{MemoryDocuments, MemoryIngest, MemoryTree};
6060
use crate::provider::knowledge::{MemoryDiff, MemoryEntities, MemoryGraph};
6161
use crate::provider::mandatory::{MemoryCore, MemoryPortability, MemoryRecall};
6262
use crate::provider::people::MemoryPeople;
63+
use crate::provider::episodic::MemoryEpisodic;
6364
use crate::provider::profile::MemoryProfile;
6465
use crate::provider::records::{
6566
MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory,
@@ -191,6 +192,11 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati
191192
None
192193
}
193194

195+
/// The turn-by-turn conversation record, when advertised.
196+
fn as_episodic(&self) -> Option<&dyn MemoryEpisodic> {
197+
None
198+
}
199+
194200
/// Whether `capability` is actually **reachable** on this driver.
195201
///
196202
/// This is the implementation-side truth, as opposed to
@@ -220,6 +226,7 @@ pub trait MemoryProvider: MemoryCore + MemoryRecall + MemoryPortability + 'stati
220226
Capability::Chunks => self.as_chunks().is_some(),
221227
Capability::Retrieval => self.as_retrieval().is_some(),
222228
Capability::Profile => self.as_profile().is_some(),
229+
Capability::Episodic => self.as_episodic().is_some(),
223230
}
224231
}
225232
}

api/src/provider/episodic.rs

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
//! The episodic family: the turn-by-turn record of conversations.
2+
//!
3+
//! A driver advertising [`Capability::Episodic`](crate::capabilities::Capability::Episodic)
4+
//! stores every chat turn in a full-text index and groups consecutive turns
5+
//! into *conversation segments* — a segment being a stretch of turns about one
6+
//! thing, closed when the subject changes and then summarised and embedded.
7+
//!
8+
//! # Why this is a family rather than a raw connection
9+
//!
10+
//! It is the last thing in the host that held a live `rusqlite::Connection`.
11+
//! The archivist hook was handed one straight out of the session factory and
12+
//! called free functions on it, which worked only because the engine was
13+
//! compiled into this process. A connection cannot cross a bus, so either the
14+
//! archivist's operations become a contract family or episodic capture stays
15+
//! behind and the engine can never leave.
16+
//!
17+
//! What crosses is small and already typed: insert a turn, read a session's
18+
//! turns back, and six segment-lifecycle operations. That was the whole surface
19+
//! the raw connection was used for — no ad-hoc SQL, no schema knowledge.
20+
//!
21+
//! # The host keeps the policy, and it is not a small share
22+
//!
23+
//! Two of the archivist's eight engine calls took no connection at all —
24+
//! deciding *whether* a new turn starts a new segment, and composing a summary
25+
//! when no model is available. Neither touches storage, so both stay host-side
26+
//! in `agent::harness::archivist`, next to the recap logic and the boundary
27+
//! thresholds they read. This family persists what the host decided; it does
28+
//! not decide.
29+
//!
30+
//! # `insert_turn` returns the id, and that is load-bearing
31+
//!
32+
//! The old code inserted a row and then issued `SELECT last_insert_rowid()` on
33+
//! the same connection to learn its id. That is two operations relying on a
34+
//! *connection-local* side effect, and it is wrong the moment anything else
35+
//! shares the connection or the two hops cross a bus — `last_insert_rowid` is
36+
//! per-connection state, so an interleaved insert from another task yields the
37+
//! wrong id and the turn is filed under the wrong segment.
38+
//!
39+
//! Returning the id from the insert removes both problems at once: one round
40+
//! trip instead of two, and no reliance on connection-local state. The engine
41+
//! knows the id it just wrote; nothing else has to guess.
42+
43+
use async_trait::async_trait;
44+
use serde::{Deserialize, Serialize};
45+
46+
use crate::error::MemoryError;
47+
48+
/// One recorded turn.
49+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
50+
pub struct EpisodicTurn {
51+
/// Row id, assigned by the driver on insert.
52+
///
53+
/// `None` when the host is describing a turn to be written; always `Some`
54+
/// on a turn read back.
55+
#[serde(default)]
56+
pub id: Option<i64>,
57+
/// Session this turn belongs to.
58+
pub session_id: String,
59+
/// When it happened, epoch seconds with sub-second resolution.
60+
///
61+
/// The archivist offsets an assistant turn by 1 ms from the user turn it
62+
/// answers so the pair sorts in order within one exchange; that convention
63+
/// is the host's and the driver must preserve the value it is given rather
64+
/// than re-stamping it.
65+
pub timestamp: f64,
66+
/// `"user"` or `"assistant"`. Open vocabulary — a driver must not reject an
67+
/// unfamiliar role.
68+
pub role: String,
69+
/// The turn's text.
70+
pub content: String,
71+
/// A short lesson extracted from tool failures, when there was one.
72+
#[serde(default)]
73+
pub lesson: Option<String>,
74+
/// Serialized tool-call summary, when the turn made any.
75+
#[serde(default)]
76+
pub tool_calls_json: Option<String>,
77+
/// Cost attributed to this turn, in microdollars.
78+
#[serde(default)]
79+
pub cost_microdollars: i64,
80+
}
81+
82+
/// A stretch of consecutive turns about one subject.
83+
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
84+
pub struct ConversationSegment {
85+
/// Stable id, chosen by the host.
86+
pub segment_id: String,
87+
/// Session the segment belongs to.
88+
pub session_id: String,
89+
/// Owning namespace.
90+
pub namespace: String,
91+
/// Row id of the first turn in the segment.
92+
pub start_episodic_id: i64,
93+
/// Row id of the last turn, once one has been appended.
94+
#[serde(default)]
95+
pub end_episodic_id: Option<i64>,
96+
/// Timestamp of the first turn.
97+
pub start_timestamp: f64,
98+
/// Timestamp of the last turn, once one has been appended.
99+
#[serde(default)]
100+
pub end_timestamp: Option<f64>,
101+
/// How many turns the segment holds.
102+
pub turn_count: i32,
103+
/// Summary, once the segment has been closed and summarised.
104+
#[serde(default)]
105+
pub summary: Option<String>,
106+
/// The segment's running embedding centroid, when it has one.
107+
///
108+
/// Carried on the read so the host can run boundary detection against it
109+
/// without a second call: deciding whether the next turn still belongs to
110+
/// this segment is host policy, but it needs the centroid the driver
111+
/// holds.
112+
#[serde(default)]
113+
pub embedding: Option<Vec<f32>>,
114+
/// Whether the segment is still open.
115+
pub open: bool,
116+
}
117+
118+
/// The turn-by-turn conversation record.
119+
///
120+
/// Reached through [`MemoryProvider::as_episodic`](super::MemoryProvider::as_episodic).
121+
#[async_trait]
122+
pub trait MemoryEpisodic: Send + Sync {
123+
/// Record one turn, returning the id the driver assigned it.
124+
///
125+
/// See the module docs for why the id comes back from the insert rather
126+
/// than from a follow-up `last_insert_rowid` call.
127+
///
128+
/// # Errors
129+
///
130+
/// Backend failures. A driver that refuses a turn on safety grounds (a
131+
/// secret-shaped session id, say) reports [`MemoryError::Invalid`] rather
132+
/// than silently dropping it — the host cannot notice a missing turn.
133+
async fn insert_turn(&self, turn: &EpisodicTurn) -> Result<i64, MemoryError>;
134+
135+
/// Every recorded turn for one session, oldest first.
136+
///
137+
/// # Errors
138+
///
139+
/// Backend failures; an unknown session yields an empty vector.
140+
async fn session_turns(&self, session_id: &str) -> Result<Vec<EpisodicTurn>, MemoryError>;
141+
142+
/// The open segment for a session, when there is one.
143+
///
144+
/// # Errors
145+
///
146+
/// Backend failures only; no open segment yields `Ok(None)`.
147+
async fn open_segment(
148+
&self,
149+
session_id: &str,
150+
) -> Result<Option<ConversationSegment>, MemoryError>;
151+
152+
/// Start a new segment at `start_episodic_id`.
153+
///
154+
/// # Errors
155+
///
156+
/// Backend failures only.
157+
async fn create_segment(
158+
&self,
159+
segment_id: &str,
160+
session_id: &str,
161+
namespace: &str,
162+
start_episodic_id: i64,
163+
start_timestamp: f64,
164+
now: f64,
165+
) -> Result<(), MemoryError>;
166+
167+
/// Extend a segment to include one more turn.
168+
///
169+
/// # Errors
170+
///
171+
/// Backend failures only.
172+
async fn append_turn(
173+
&self,
174+
segment_id: &str,
175+
episodic_id: i64,
176+
timestamp: f64,
177+
now: f64,
178+
) -> Result<(), MemoryError>;
179+
180+
/// Mark a segment closed. Idempotent.
181+
///
182+
/// # Errors
183+
///
184+
/// Backend failures only.
185+
async fn close_segment(&self, segment_id: &str, now: f64) -> Result<(), MemoryError>;
186+
187+
/// Attach a summary to a segment.
188+
///
189+
/// Separate from [`Self::close_segment`] because the two happen at
190+
/// different times: a segment closes the moment the subject changes, and is
191+
/// summarised afterwards by a model call that may be slow, may fail, or may
192+
/// fall back to a composed summary. Folding them together would mean either
193+
/// holding the segment open across an inference call or losing the summary
194+
/// when one fails.
195+
///
196+
/// # Errors
197+
///
198+
/// Backend failures only.
199+
async fn set_segment_summary(
200+
&self,
201+
segment_id: &str,
202+
summary: &str,
203+
now: f64,
204+
) -> Result<(), MemoryError>;
205+
206+
/// Store a segment's embedding under `model_signature`, replacing any
207+
/// vector already held for that signature.
208+
///
209+
/// The signature must be produced the same way the rest of the store
210+
/// produces it — see `docs/specs/2026-08-13-memory-module-port.md` §3 for
211+
/// why a mismatch here is silent.
212+
///
213+
/// # Errors
214+
///
215+
/// Backend failures only.
216+
async fn upsert_segment_embedding(
217+
&self,
218+
segment_id: &str,
219+
model_signature: &str,
220+
embedding: &[f32],
221+
created_at: f64,
222+
) -> Result<(), MemoryError>;
223+
}

api/src/provider/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
//! ├─ as_people() -> Option<&dyn MemoryPeople>
2222
//! ├─ as_chunks() -> Option<&dyn MemoryChunks>
2323
//! ├─ as_retrieval() -> Option<&dyn MemoryRetrieval>
24-
//! └─ as_profile() -> Option<&dyn MemoryProfile>
24+
//! ├─ as_profile() -> Option<&dyn MemoryProfile>
25+
//! └─ as_episodic() -> Option<&dyn MemoryEpisodic>
2526
//! ```
2627
//!
2728
//! The mandatory three are supertraits, so "mandatory" is enforced by the type
@@ -63,6 +64,7 @@ pub mod driver;
6364
pub mod knowledge;
6465
pub mod mandatory;
6566
pub mod people;
67+
pub mod episodic;
6668
pub mod profile;
6769
pub mod records;
6870
pub mod retrieval;
@@ -78,6 +80,7 @@ pub use people::{
7880
AddressBookSeedOutcome, MemoryPeople, PersonHandle, PersonInteraction, PersonRecord, PersonRef,
7981
PersonScore, RankedPerson, ResolvedPerson,
8082
};
83+
pub use episodic::{ConversationSegment, EpisodicTurn, MemoryEpisodic};
8184
pub use profile::{FacetState, FacetType, MemoryProfile, ProfileFacet, UserState};
8285
pub use records::{MemoryGoals, MemoryMaintenance, MemorySourceSink, MemoryToolMemory};
8386
pub use retrieval::{

0 commit comments

Comments
 (0)