Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 364ecff391 |
@@ -631,38 +631,12 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
return "{\"error\":\"message required\",\"reply\":\"\"}"
|
||||
}
|
||||
|
||||
// Workspace scope (#23): the desktop UI sends the user-chosen Agent Workspace root
|
||||
// on every agentic request. Persist it to state so agent_workspace_root() — and the
|
||||
// path/command tool guards that read it — confine this turn's file/command tools to
|
||||
// that subtree. The UI is the source of truth per request: empty means unscoped (the
|
||||
// backward-compatible default), and it also lets agent_workspace_root() fall through
|
||||
// to the NEURON_AGENT_ROOT env when no root is sent. FLAGGED FOR REVIEW: setting
|
||||
// state from the body each turn (vs. only-when-nonempty) so clearing the folder in
|
||||
// the UI un-scopes — confirm this is the intended ownership model.
|
||||
let ws_root: String = json_get(body, "agent_workspace_root")
|
||||
state_set("agent_workspace_root", ws_root)
|
||||
|
||||
let req_model: String = json_get(body, "model")
|
||||
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
|
||||
|
||||
// Thread-aware activation: same logic as handle_chat.
|
||||
// Use the session's or global history to anchor short messages to the thread.
|
||||
let req_session: String = json_get(body, "session_id")
|
||||
|
||||
// ISSUE #6/#7: validate that the session_id actually exists before proceeding.
|
||||
// Without this check the loop silently treats any unknown/fabricated session_id
|
||||
// as a fresh session — history loads as empty and no error is returned to the caller.
|
||||
// Only validate when a session_id is explicitly provided; anonymous calls
|
||||
// (no session_id) continue to work for backward compatibility.
|
||||
let session_valid: Bool = if str_eq(req_session, "") {
|
||||
true
|
||||
} else {
|
||||
session_exists(req_session)
|
||||
}
|
||||
if !session_valid {
|
||||
return "{\"error\":\"session not found\",\"session_id\":\"" + req_session + "\",\"reply\":\"\"}"
|
||||
}
|
||||
|
||||
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
||||
let agentic_hist: String = state_get(hist_key)
|
||||
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Design proposal: searchable, recency-aware conversation memory
|
||||
|
||||
Status: **proposal — for Tim + Will, no code yet**
|
||||
Author: Neuron (Claude Opus 4.8), 2026-06-21
|
||||
Trigger: "Summarize the key themes across my recent conversations" returns nothing useful.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
Conversations **are** being persisted — `auto_persist` writes every turn as a
|
||||
timestamped `Conversation`/`Episodic` node. The failure is **retrieval**, not
|
||||
storage. Two gaps:
|
||||
|
||||
1. **No recency-ordered retrieval.** There is no way to ask "give me my last N
|
||||
conversation turns by time." Search is keyword-ranked only.
|
||||
2. **Lexical-only search.** `search_memory` → `engram_search_json` is BM25/lexical.
|
||||
A semantic/thematic query ("themes across recent conversations") doesn't share
|
||||
keywords with the actual topic content, so it misses.
|
||||
|
||||
The model literally tried to express the missing capability in the fake tool call
|
||||
it hallucinated: `"recency_weight": 0.8`, `"sort_by": "recency"`,
|
||||
`node_type: "ConversationTurn"`. It wanted a recency-windowed conversation fetch
|
||||
that doesn't exist.
|
||||
|
||||
## What exists today (verified)
|
||||
|
||||
- `auto_persist(req, resp)` (chat.el): after each non-agentic turn, stores
|
||||
`{"q","a","created_at","source":"chat","label":"chat:<ts>"}` as
|
||||
`engram_node_full(... "Conversation" ... "Episodic" ...)`, tags
|
||||
`["Conversation","chat","timestamped"]`.
|
||||
- `conv_history_persist` (chat.el): a **single overwriting** `conv:history`
|
||||
Episodic node holding the rolling JSON history (continuity across restarts) —
|
||||
not per-turn, not individually searchable.
|
||||
- Live engram (founder instance): **5,113 nodes, 59 conversation nodes** — a mix
|
||||
of `chat:<ts>`, several `conv:history` copies, and older `Q:/A:` nodes.
|
||||
- Retrieval surface for the agentic loop: `search_memory`, `recall`,
|
||||
`neuron_search_knowledge`, `neuron_recall` — all **query-keyword** based.
|
||||
None is "most recent N by time," none is embedding/semantic.
|
||||
|
||||
## The gap, precisely
|
||||
|
||||
| User intent | Needs | Have today |
|
||||
|---|---|---|
|
||||
| "summarize my recent conversations" | last-N-by-time fetch | ✗ (keyword only) |
|
||||
| "what did we discuss about X" | semantic match on topic | ~ (lexical only; misses paraphrase) |
|
||||
| "themes across everything" | semantic cluster over corpus | ✗ |
|
||||
|
||||
`auto_persist` only fires on the **non-agentic** path (`handle_chat`). Worth
|
||||
confirming the **agentic** path (`handle_chat_agentic`) persists turns too — if
|
||||
not, agentic conversations never get stored, a second (smaller) gap.
|
||||
|
||||
## Proposal
|
||||
|
||||
Three layers, smallest-first. (1) alone fixes the headline use case.
|
||||
|
||||
### 1. Recency-windowed conversation retrieval (the high-value, low-cost win)
|
||||
A runtime/engram primitive + an agentic tool:
|
||||
|
||||
- **Engram**: `engram_recent_by_type(node_type, limit, since_ts?)` → newest-first
|
||||
by `created_at`. (Conversation nodes already carry `created_at`.)
|
||||
- **Agentic tool**: `recent_conversations(limit=20, since?)` →
|
||||
`[{q,a,created_at}, …]`, newest first. Exposed in `agentic_tools_all`.
|
||||
- **System-prompt hint**: for "recent / lately / this week / summarize our
|
||||
conversations," prefer `recent_conversations` over `search_memory`.
|
||||
|
||||
This directly answers "summarize my recent conversations" — fetch last N, hand
|
||||
the model the actual turns, let it cluster themes. No embeddings required.
|
||||
|
||||
### 2. Stable per-session threading
|
||||
Today each turn is an independent `chat:<ts>` node; there's no session grouping.
|
||||
Add `session_id` + a monotonic turn index to the persisted content (the UI already
|
||||
sends `session_id`). Enables "summarize *this* conversation" and per-session recall,
|
||||
and lets retrieval return coherent threads instead of loose turns.
|
||||
|
||||
### 3. Semantic retrieval (the real fix for thematic queries)
|
||||
Lexical BM25 can't do "themes." Options, in order of effort:
|
||||
- **a.** Embeddings on Conversation nodes + a vector search tool
|
||||
(`semantic_search`). Biggest lift; also fixes knowledge recall broadly.
|
||||
- **b.** Interim: a two-pass "map-reduce" — `recent_conversations` to pull the
|
||||
window, then let the model cluster. Cheap, ships with (1), no infra.
|
||||
|
||||
Recommend **(1) + (2) now, (3b) as the interim thematic answer, (3a) as the
|
||||
roadmap item** once embeddings land (this dovetails with the GraphRAG/embedding
|
||||
work already noted in memory: substring 1.7% P@5 vs BM25 55% vs graph 21.7%).
|
||||
|
||||
## Open questions for Will
|
||||
1. ~~Does the agentic path persist turns?~~ **Resolved: yes** — the dispatcher
|
||||
calls `auto_persist` after both the agentic and non-agentic branches
|
||||
(`routes.el` lines 156/298). Both paths store per-turn nodes.
|
||||
2. `conv:history` is accumulating duplicate overwriting nodes (saw several in the
|
||||
live engram) — intended, or should it truly overwrite/dedupe?
|
||||
3. Is there appetite for the `engram_recent_by_type` primitive in the runtime, or
|
||||
should recency be done in `.el` by scanning + sorting (fine at 59 nodes, weak
|
||||
at scale)?
|
||||
4. Embeddings (3a): on the roadmap timeline, or defer and ship (1)+(2)+(3b)?
|
||||
|
||||
## Not in scope
|
||||
Persistence itself (it works), and the separate **confabulation** fix (model
|
||||
faking tool calls in Just-chat mode) — that's `neuron` PR #29.
|
||||
+1
-97
@@ -36,49 +36,7 @@ fn session_make_content(id: String, title: String, created_at: Int, updated_at:
|
||||
+ ",\"updated_at\":" + int_to_str(updated_at) + "}"
|
||||
}
|
||||
|
||||
// session_exists — return true if the given session_id is known in Engram or state.
|
||||
// Used by chat.el to validate a session_id before processing a chat message.
|
||||
// Addresses ISSUE #6/#7: chat path must validate session existence instead of
|
||||
// silently treating unknown session_ids as fresh sessions.
|
||||
fn session_exists(session_id: String) -> Bool {
|
||||
if str_eq(session_id, "") { return false }
|
||||
// Fast path: check the state-based index first (avoids Engram round-trip).
|
||||
let idx: String = state_get("session_index")
|
||||
if !str_eq(idx, "") && !str_eq(idx, "[]") {
|
||||
if str_contains(idx, "\"id\":\"" + session_id + "\"") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Slow path: check Engram directly (survives restarts when index is cold).
|
||||
let results: String = engram_search_json("session:meta " + session_id, 5)
|
||||
if str_eq(results, "") { return false }
|
||||
if str_eq(results, "[]") { return false }
|
||||
let total: Int = json_array_len(results)
|
||||
let found: Bool = false
|
||||
let i: Int = 0
|
||||
while i < total {
|
||||
let node: String = json_array_get(results, i)
|
||||
let label: String = json_get(node, "label")
|
||||
let content: String = json_get(node, "content")
|
||||
let sid: String = json_get(content, "id")
|
||||
let is_match: Bool = str_eq(label, "session:meta") && str_eq(sid, session_id)
|
||||
let found = if is_match { true } else { found }
|
||||
let i = i + 1
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
// session_create — create a new session, return {id, title, created_at}.
|
||||
//
|
||||
// ISSUE #1: Ghost sessions on failed first message.
|
||||
// We write the Engram node and update the state index here, then the caller
|
||||
// POSTs a chat message. If that chat call fails (LLM unavailable, network
|
||||
// error, etc.) the session is stranded with no messages. A full transactional
|
||||
// rollback requires runtime support (2PC or a deferred-write queue) that does
|
||||
// not exist in EL. Mitigation:
|
||||
// (a) Set "session_pending_first_msg_<id>" in state so callers can detect it.
|
||||
// (b) Provide session_create_cleanup() for callers that detect a failure.
|
||||
// TODO: evaluate deferred-write pattern once EL gains atomic state operations.
|
||||
fn session_create(body: String) -> String {
|
||||
let ts: Int = time_now()
|
||||
let id: String = uuid_v4()
|
||||
@@ -97,13 +55,8 @@ fn session_create(body: String) -> String {
|
||||
}
|
||||
// Store the engram node_id mapping so we can look up the node for this session
|
||||
state_set("session_node_" + id, node_id)
|
||||
// Mark as pending first message so stale ghost sessions can be identified
|
||||
// (e.g. if the caller\'s subsequent chat POST fails).
|
||||
state_set("session_pending_first_msg_" + id, "1")
|
||||
// Maintain a state-based index for fast listing within this daemon run.
|
||||
// Newest sessions first (prepend).
|
||||
// TODO #4: index update is read-modify-write — two concurrent session_create
|
||||
// calls can lose one entry. EL has no CAS primitive; fix requires runtime support.
|
||||
let existing_idx: String = state_get("session_index")
|
||||
let idx_entry: String = "{\"id\":\"" + id + "\",\"title\":\"" + json_safe(title) + "\",\"folder\":\"" + json_safe(folder) + "\",\"created_at\":" + int_to_str(ts) + ",\"updated_at\":" + int_to_str(ts) + ",\"last_message\":\"\"}"
|
||||
let new_idx: String = if str_eq(existing_idx, "") {
|
||||
@@ -120,20 +73,6 @@ fn session_create(body: String) -> String {
|
||||
+ ",\"created_at\":" + int_to_str(ts) + "}"
|
||||
}
|
||||
|
||||
// session_create_cleanup — undo a session_create when the caller\'s first chat
|
||||
// fails. Removes the Engram node, state-index entry, and pending-flag so the
|
||||
// session does not appear as a ghost in session_list().
|
||||
// Addresses ISSUE #1: cleanup path for ghost sessions.
|
||||
fn session_create_cleanup(session_id: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "{\"error\":\"session_id is required\"}"
|
||||
}
|
||||
// Clear pending flag first so partial cleanup is still detectable.
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
// Delegate to session_delete which handles Engram + state index teardown.
|
||||
return session_delete(session_id)
|
||||
}
|
||||
|
||||
// session_list — list all sessions. Returns [{id, title, last_message, created_at, updated_at}].
|
||||
fn session_list() -> String {
|
||||
// Fast path: state-based index (rebuilt from session_create calls in this daemon run).
|
||||
@@ -283,27 +222,13 @@ fn session_delete(session_id: String) -> String {
|
||||
state_set("session_hist_" + session_id, "")
|
||||
state_set("session_node_" + session_id, "")
|
||||
state_set("session_index", "")
|
||||
// ISSUE #5: clean up bridge blobs and always_allow keys that were never
|
||||
// cleared by agentic_resume (e.g. client abandoned a pending tool call).
|
||||
// Without this, stranded bridge blobs accumulate indefinitely in state.
|
||||
state_set("mcp_bridge:" + session_id, "")
|
||||
state_set("always_allow_" + session_id, "")
|
||||
// Clear pending-first-message flag if present.
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
return "{\"ok\":true,\"session_id\":\"" + session_id + "\""
|
||||
+ ",\"deleted_meta\":" + int_to_str(deleted_meta)
|
||||
+ ",\"deleted_msgs\":" + int_to_str(deleted_msgs) + "}"
|
||||
}
|
||||
|
||||
// session_update_patch — update a session\'s title and/or folder via PATCH body.
|
||||
// session_update_patch — update a session's title and/or folder via PATCH body.
|
||||
// Body may contain "title", "folder", or both. Preserves unmentioned fields.
|
||||
//
|
||||
// ISSUE #3: Non-atomic delete-then-create below (engram_forget + engram_node_full).
|
||||
// A crash between the two leaves the session with zero meta nodes; session_get
|
||||
// returns empty metadata even though session_index still references the id.
|
||||
// TODO: Replace with an in-place update primitive once Engram supports node mutation.
|
||||
// Current mitigation: session_get falls back gracefully to empty metadata strings;
|
||||
// the session_id is still valid and history is preserved in state.
|
||||
fn session_update_patch(session_id: String, body: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "{\"error\":\"session_id is required\"}"
|
||||
@@ -424,9 +349,6 @@ fn session_hist_load(session_id: String) -> String {
|
||||
// session_hist_save — persist message history for a session to state and engram.
|
||||
fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
state_set("session_hist_" + session_id, hist)
|
||||
// Clear pending-first-message flag: once history is saved, the session
|
||||
// is no longer in the ghost/pending state (ISSUE #1 mitigation).
|
||||
state_set("session_pending_first_msg_" + session_id, "")
|
||||
// Delete old history node and write fresh one
|
||||
let old_results: String = engram_search_json("session:messages:" + session_id, 3)
|
||||
let o_total: Int = if str_eq(old_results, "") { 0 } else { json_array_len(old_results) }
|
||||
@@ -449,16 +371,6 @@ fn session_hist_save(session_id: String, hist: String) -> Void {
|
||||
}
|
||||
|
||||
// session_update_meta_timestamp — update the updated_at field in the session:meta node.
|
||||
//
|
||||
// ISSUE #2: No TTL / idle expiry mechanism. Sessions accumulate indefinitely.
|
||||
// A sweep job (e.g. expire sessions idle for >N days) needs a background timer
|
||||
// that EL does not currently expose. Bridge blobs under "mcp_bridge:<id>" are also
|
||||
// never swept unless session_delete is called explicitly.
|
||||
// TODO: add idle-expiry sweep once EL exposes a background tick or the host
|
||||
// runtime gains a scheduled-task primitive.
|
||||
//
|
||||
// ISSUE #3 applies here too: delete-then-create is non-atomic. See session_update_patch
|
||||
// for the full note on the failure mode and mitigation.
|
||||
fn session_update_meta_timestamp(session_id: String) -> Void {
|
||||
let results: String = engram_search_json("session:meta " + session_id, 10)
|
||||
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
||||
@@ -552,14 +464,6 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
|
||||
// action: "allow" | "deny" | "always"
|
||||
// Resumes the agentic loop from where it was paused.
|
||||
//
|
||||
// ISSUE #8: Reconnect/duplicate resume race. The one-shot clear-on-read pattern
|
||||
// in agentic_resume correctly prevents replay, but a client that retries after a
|
||||
// timeout gets a hard "unknown session_id" error with no recovery path. The
|
||||
// conversation is permanently stuck in that case. Full idempotency (e.g. caching
|
||||
// the last reply keyed by call_id) requires a new state structure.
|
||||
// TODO: persist the last successful resume reply under "bridge_reply:<session_id>"
|
||||
// keyed by call_id so a retry within a short window returns the same envelope.
|
||||
//
|
||||
// Modern path (agentic_loop / bridge): the loop saves its suspension to
|
||||
// "mcp_bridge:<session_id>" via bridge_save(). On approval we dispatch_tool()
|
||||
// if allowed (or build a denial string), then hand the result to agentic_resume()
|
||||
|
||||
Reference in New Issue
Block a user