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
8 changes: 7 additions & 1 deletion crates/tinymemory-api/src/item/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ impl StoreItem {
}

/// A stable hex digest of the whole item, metadata included, except
/// `meta.observed_at` and `meta.observed_actor`.
/// `meta.observed_at`, `meta.observed_actor` and `meta.tool_call.id`.
///
/// Two items with the same fingerprint are the same item: an engine
/// derives its idempotency from this, so an identical retry is a replay.
Expand All @@ -277,6 +277,12 @@ impl StoreItem {
let mut identity = self.clone();
identity.meta_mut().observed_at = None;
identity.meta_mut().observed_actor = None;
// The provider assigns a fresh invocation id on every call. It is
// provenance, not learning content: retrying a successful write must
// replay the same item even when the model issues a new tool call.
if let Some(call) = identity.meta_mut().tool_call.as_mut() {
call.id = None;
Comment on lines +283 to +284

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve IDs for existing tool-call items

When upgrading a store containing an item with meta.tool_call.id, that item remains labeled with the previous fingerprint, which included the ID, while this code now computes a different fingerprint after clearing it. CortexEngine::store_items looks up only the newly computed ID, so re-storing the same learning after an upgrade writes a duplicate instead of returning the existing record as a replay; reconstructed items also no longer satisfy the documented id == item.fingerprint() invariant. Add a compatibility lookup or migration for the legacy fingerprint before changing this public identity behavior.

AGENTS.md reference: AGENTS.md:L248-L249

Useful? React with 👍 / 👎.

}
// Serialising a struct cannot fail: every field is a plain string,
// number, enum or timestamp. The fallback keeps the function total.
let bytes =
Expand Down
27 changes: 27 additions & 0 deletions crates/tinymemory-api/src/item/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,33 @@ fn fingerprints_ignore_who_an_item_is_attributed_to() {
);
}

#[test]
fn retried_learning_ignores_the_provider_tool_call_id() {
let mut first = StoreItem::learning(
"Prefers tea",
LearningKind::Preference,
0.8,
MemoryMeta::default(),
);
first.meta_mut().namespace = "user:alice".parse().unwrap();
first.meta_mut().tool_call = Some(ToolCallRef {
name: "memory".into(),
id: Some("call-first".into()),
});
let mut retry = first.clone();
retry.meta_mut().tool_call.as_mut().unwrap().id = Some("call-retry".into());
assert_eq!(first.fingerprint(), retry.fingerprint());
assert_eq!(
first.meta().tool_call.as_ref().unwrap().id.as_deref(),
Some("call-first")
);
retry.meta_mut().namespace = "user:bob".parse().unwrap();
assert_ne!(first.fingerprint(), retry.fingerprint());
retry.meta_mut().namespace = first.meta().namespace.clone();
retry.meta_mut().tool_call.as_mut().unwrap().name = "another-tool".into();
assert_ne!(first.fingerprint(), retry.fingerprint());
}

#[test]
fn items_serialise_with_a_type_tag_and_round_trip() {
let item = StoreItem::learning("tea", LearningKind::Preference, 0.9, MemoryMeta::default());
Expand Down
7 changes: 6 additions & 1 deletion docs/architecture/api-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ enforce (`Namespace` is checked when parsed).

`fingerprint()` is a stable 40-character lowercase hex string: the first 20
bytes of the SHA-256 of the item's JSON serialisation, with
`meta.observed_at` and `meta.observed_actor` cleared first. It covers **everything else**: kind, text,
`meta.observed_at`, `meta.observed_actor` and `meta.tool_call.id` cleared first. It covers **everything else**: kind, text,
title, turns, learning kind, confidence, evidence, and every metadata field
including `namespace` (a root namespace is not serialised, so root items hash
as they did before namespaces existed).
Expand All @@ -124,6 +124,11 @@ learning, or an unchanged file re-synced, into a new item each time.
attribution on; hashing it would store the same email again once that is
turned on.

`meta.tool_call.id` identifies a provider invocation, so a new call that retries
the same learning must not create a duplicate. The tool name still participates
in identity, and the stored metadata retains the original invocation id. Tool
calls inside conversation turns remain part of the conversation's content.

Two items with the same fingerprint are the same item. Engines derive
idempotency from it: the reference engine uses the fingerprint as the item id.
See [operations.md](operations.md#idempotency-and-fingerprints).
Expand Down
Loading