613 lines
29 KiB
EmacsLisp
613 lines
29 KiB
EmacsLisp
import "memory.el"
|
|
import "chat.el"
|
|
|
|
// sessions.el — Persistent conversation session management.
|
|
//
|
|
// Sessions are Engram nodes with:
|
|
// node_type = "Conversation"
|
|
// label = "session:meta"
|
|
// content = JSON: {id, title, created_at, updated_at}
|
|
//
|
|
// Message history is kept in state under "session_hist_SESSION_ID"
|
|
// and also persisted to Engram as nodes with label "session:messages:SESSION_ID".
|
|
|
|
// session_title_from_message — derive a session title from the first user message.
|
|
// Takes up to 60 characters; falls back to "New conversation".
|
|
fn session_title_from_message(message: String) -> String {
|
|
if str_eq(message, "") { return "New conversation" }
|
|
let trimmed: String = str_trim(message)
|
|
if str_len(trimmed) <= 60 {
|
|
return trimmed
|
|
}
|
|
return str_slice(trimmed, 0, 60)
|
|
}
|
|
|
|
// session_make_content — build the JSON blob stored as session:meta node content.
|
|
// IMPORTANT: "type":"session:meta" must appear in the content so engram_search_json
|
|
// can find these nodes by text search. Do not remove it.
|
|
fn session_make_content(id: String, title: String, created_at: Int, updated_at: Int, folder: String) -> String {
|
|
let safe_title: String = json_safe(title)
|
|
let safe_folder: String = json_safe(folder)
|
|
return "{\"type\":\"session:meta\""
|
|
+ ",\"id\":\"" + id + "\""
|
|
+ ",\"title\":\"" + safe_title + "\""
|
|
+ ",\"folder\":\"" + safe_folder + "\""
|
|
+ ",\"created_at\":" + int_to_str(created_at)
|
|
+ ",\"updated_at\":" + int_to_str(updated_at) + "}"
|
|
}
|
|
|
|
// session_create — create a new session, return {id, title, created_at}.
|
|
fn session_create(body: String) -> String {
|
|
let ts: Int = time_now()
|
|
let id: String = uuid_v4()
|
|
let title_req: String = json_get(body, "title")
|
|
let title: String = if str_eq(title_req, "") { "New conversation" } else { title_req }
|
|
let folder: String = json_get(body, "folder")
|
|
let content: String = session_make_content(id, title, ts, ts, folder)
|
|
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
|
let node_id: String = engram_node_full(
|
|
content, "Conversation", "session:meta",
|
|
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
if str_eq(node_id, "") {
|
|
return "{\"error\":\"failed to create session\"}"
|
|
}
|
|
// Store the engram node_id mapping so we can look up the node for this session
|
|
state_set("session_node_" + id, node_id)
|
|
// Maintain a state-based index for fast listing within this daemon run.
|
|
// Newest sessions first (prepend).
|
|
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, "") {
|
|
"[" + idx_entry + "]"
|
|
} else {
|
|
let inner: String = str_slice(existing_idx, 1, str_len(existing_idx) - 1)
|
|
"[" + idx_entry + "," + inner + "]"
|
|
}
|
|
state_set("session_index", new_idx)
|
|
return "{\"id\":\"" + id + "\""
|
|
+ ",\"title\":\"" + json_safe(title) + "\""
|
|
+ ",\"folder\":\"" + json_safe(folder) + "\""
|
|
+ ",\"node_id\":\"" + node_id + "\""
|
|
+ ",\"created_at\":" + int_to_str(ts) + "}"
|
|
}
|
|
|
|
// 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).
|
|
let state_idx: String = state_get("session_index")
|
|
if !str_eq(state_idx, "") && !str_eq(state_idx, "[]") {
|
|
return state_idx
|
|
}
|
|
// Slow path: engram search (works across restarts for new-format nodes).
|
|
let results: String = engram_search_json("session:meta", 50)
|
|
if str_eq(results, "") { return "[]" }
|
|
if str_eq(results, "[]") { return "[]" }
|
|
// Filter to only session:meta nodes; build output array
|
|
let total: Int = json_array_len(results)
|
|
let out: String = ""
|
|
let i: Int = 0
|
|
while i < total {
|
|
let node: String = json_array_get(results, i)
|
|
let label: String = json_get(node, "label")
|
|
let node_type: String = json_get(node, "node_type")
|
|
let is_session: Bool = str_eq(label, "session:meta") && str_eq(node_type, "Conversation")
|
|
let content: String = json_get(node, "content")
|
|
let sess_id: String = json_get(content, "id")
|
|
// Use the nested content JSON fields
|
|
let eff_id: String = if str_eq(sess_id, "") { json_get(node, "id") } else { sess_id }
|
|
let title_inner: String = json_get(content, "title")
|
|
let eff_title: String = if str_eq(title_inner, "") { "New conversation" } else { title_inner }
|
|
let folder_inner: String = json_get(content, "folder")
|
|
let created_inner: String = json_get(content, "created_at")
|
|
let updated_inner: String = json_get(content, "updated_at")
|
|
let eff_created: String = if str_eq(created_inner, "") { "0" } else { created_inner }
|
|
let eff_updated: String = if str_eq(updated_inner, "") { eff_created } else { updated_inner }
|
|
|
|
let entry: String = if is_session {
|
|
"{\"id\":\"" + json_safe(eff_id) + "\""
|
|
+ ",\"title\":\"" + json_safe(eff_title) + "\""
|
|
+ ",\"folder\":\"" + json_safe(folder_inner) + "\""
|
|
+ ",\"last_message\":\"\""
|
|
+ ",\"created_at\":" + eff_created
|
|
+ ",\"updated_at\":" + eff_updated + "}"
|
|
} else { "" }
|
|
|
|
let out = if !str_eq(entry, "") {
|
|
if str_eq(out, "") { entry } else { out + "," + entry }
|
|
} else { out }
|
|
let i = i + 1
|
|
}
|
|
return "[" + out + "]"
|
|
}
|
|
|
|
// session_get — get a session's metadata + message history.
|
|
// Returns {id, title, created_at, updated_at, messages: [{role, content, timestamp}]}
|
|
fn session_get(session_id: String) -> String {
|
|
if str_eq(session_id, "") {
|
|
return "{\"error\":\"session_id is required\"}"
|
|
}
|
|
// Load session meta from engram
|
|
let results: String = engram_search_json("session:meta " + session_id, 10)
|
|
let meta_content: String = ""
|
|
let meta_title: String = "New conversation"
|
|
let meta_folder: String = ""
|
|
let meta_created: String = "0"
|
|
let meta_updated: String = "0"
|
|
let found: Bool = false
|
|
|
|
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
|
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) && !found
|
|
let found = if is_match { true } else { found }
|
|
let meta_title = if is_match { json_get(content, "title") } else { meta_title }
|
|
let meta_folder = if is_match { json_get(content, "folder") } else { meta_folder }
|
|
let meta_created_raw: String = json_get(content, "created_at")
|
|
let meta_created = if is_match && !str_eq(meta_created_raw, "") { meta_created_raw } else { meta_created }
|
|
let meta_updated_raw: String = json_get(content, "updated_at")
|
|
let meta_updated = if is_match && !str_eq(meta_updated_raw, "") { meta_updated_raw } else { meta_updated }
|
|
let i = i + 1
|
|
}
|
|
|
|
// Load message history from state (primary) or engram (fallback)
|
|
let state_hist: String = state_get("session_hist_" + session_id)
|
|
let hist_raw: String = if str_eq(state_hist, "") {
|
|
// Try loading from engram
|
|
let engram_hist: String = engram_search_json("session:messages:" + session_id, 3)
|
|
if str_eq(engram_hist, "") { "[]" } else {
|
|
if str_eq(engram_hist, "[]") { "[]" } else {
|
|
let h_node: String = json_array_get(engram_hist, 0)
|
|
let h_content: String = json_get(h_node, "content")
|
|
if str_starts_with(h_content, "[") { h_content } else { "[]" }
|
|
}
|
|
}
|
|
} else { state_hist }
|
|
|
|
let safe_title: String = json_safe(meta_title)
|
|
return "{\"id\":\"" + session_id + "\""
|
|
+ ",\"title\":\"" + safe_title + "\""
|
|
+ ",\"folder\":\"" + json_safe(meta_folder) + "\""
|
|
+ ",\"created_at\":" + meta_created
|
|
+ ",\"updated_at\":" + meta_updated
|
|
+ ",\"messages\":" + hist_raw + "}"
|
|
}
|
|
|
|
// session_delete — delete a session and its history nodes from engram.
|
|
fn session_delete(session_id: String) -> String {
|
|
if str_eq(session_id, "") {
|
|
return "{\"error\":\"session_id is required\"}"
|
|
}
|
|
// Find and delete session:meta node
|
|
let results: String = engram_search_json("session:meta " + session_id, 10)
|
|
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
|
let deleted_meta: Int = 0
|
|
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 node_id: String = json_get(node, "id")
|
|
let deleted_meta = if is_match && !str_eq(node_id, "") {
|
|
engram_forget(node_id)
|
|
deleted_meta + 1
|
|
} else { deleted_meta }
|
|
let i = i + 1
|
|
}
|
|
// Find and delete session:messages:SESSION_ID nodes
|
|
let msg_results: String = engram_search_json("session:messages:" + session_id, 10)
|
|
let m_total: Int = if str_eq(msg_results, "") { 0 } else { json_array_len(msg_results) }
|
|
let deleted_msgs: Int = 0
|
|
let j: Int = 0
|
|
while j < m_total {
|
|
let node: String = json_array_get(msg_results, j)
|
|
let label: String = json_get(node, "label")
|
|
let is_msgs: Bool = str_eq(label, "session:messages:" + session_id)
|
|
let node_id: String = json_get(node, "id")
|
|
let deleted_msgs = if is_msgs && !str_eq(node_id, "") {
|
|
engram_forget(node_id)
|
|
deleted_msgs + 1
|
|
} else { deleted_msgs }
|
|
let j = j + 1
|
|
}
|
|
// Clear state — invalidate all per-session and index caches so session_list()
|
|
// does not return this deleted session via the fast path on the next call.
|
|
state_set("session_hist_" + session_id, "")
|
|
state_set("session_node_" + session_id, "")
|
|
state_set("session_index", "")
|
|
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.
|
|
// Body may contain "title", "folder", or both. Preserves unmentioned fields.
|
|
fn session_update_patch(session_id: String, body: String) -> String {
|
|
if str_eq(session_id, "") {
|
|
return "{\"error\":\"session_id is required\"}"
|
|
}
|
|
let has_title: Bool = str_contains(body, "\"title\"")
|
|
let has_folder: Bool = str_contains(body, "\"folder\"")
|
|
if !has_title && !has_folder {
|
|
return "{\"error\":\"title or folder required in body\"}"
|
|
}
|
|
// Find the existing session:meta node.
|
|
// Use broad label search (not UUID search) because Engram text search
|
|
// does not reliably match UUID strings with dashes.
|
|
let results: String = engram_search_json("session:meta", 50)
|
|
let total: Int = if str_eq(results, "") { 0 } else { json_array_len(results) }
|
|
let found: Bool = false
|
|
let old_title: String = "New conversation"
|
|
let old_folder: String = ""
|
|
let old_created: String = "0"
|
|
let old_node_id: String = ""
|
|
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) && !found
|
|
let found = if is_match { true } else { found }
|
|
let title_raw: String = json_get(content, "title")
|
|
let old_title = if is_match && !str_eq(title_raw, "") { title_raw } else { old_title }
|
|
let folder_raw: String = json_get(content, "folder")
|
|
let old_folder = if is_match { folder_raw } else { old_folder }
|
|
let created_raw: String = json_get(content, "created_at")
|
|
let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created }
|
|
let nid: String = json_get(node, "id")
|
|
let old_node_id = if is_match { nid } else { old_node_id }
|
|
let i = i + 1
|
|
}
|
|
if !found {
|
|
return "{\"error\":\"session not found\",\"session_id\":\"" + session_id + "\"}"
|
|
}
|
|
// Apply updates — preserve field if not in body
|
|
let req_title: String = json_get(body, "title")
|
|
let eff_title: String = if has_title && !str_eq(req_title, "") { req_title } else { old_title }
|
|
let eff_folder: String = if has_folder { json_get(body, "folder") } else { old_folder }
|
|
// Delete old node, create updated one
|
|
if !str_eq(old_node_id, "") {
|
|
engram_forget(old_node_id)
|
|
}
|
|
let ts: Int = time_now()
|
|
let created_int: Int = str_to_int(old_created)
|
|
let new_content: String = session_make_content(session_id, eff_title, created_int, ts, eff_folder)
|
|
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
|
let new_node_id: String = engram_node_full(
|
|
new_content, "Conversation", "session:meta",
|
|
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
state_set("session_node_" + session_id, new_node_id)
|
|
// Invalidate the session_index state cache so session_list re-fetches
|
|
// from Engram on the next call (the updated node has the new folder/title).
|
|
state_set("session_index", "")
|
|
return "{\"ok\":true,\"id\":\"" + session_id + "\""
|
|
+ ",\"title\":\"" + json_safe(eff_title) + "\""
|
|
+ ",\"folder\":\"" + json_safe(eff_folder) + "\""
|
|
+ ",\"updated_at\":" + int_to_str(ts) + "}"
|
|
}
|
|
|
|
// session_search — search session:meta nodes whose content matches query.
|
|
fn session_search(query: String) -> String {
|
|
if str_eq(query, "") { return "[]" }
|
|
let results: String = engram_search_json("session:meta " + query, 20)
|
|
if str_eq(results, "") { return "[]" }
|
|
if str_eq(results, "[]") { return "[]" }
|
|
let total: Int = json_array_len(results)
|
|
let out: String = ""
|
|
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 is_session: Bool = str_eq(label, "session:meta")
|
|
let sess_id: String = json_get(content, "id")
|
|
let title: String = json_get(content, "title")
|
|
let created_raw: String = json_get(content, "created_at")
|
|
let updated_raw: String = json_get(content, "updated_at")
|
|
let eff_created: String = if str_eq(created_raw, "") { "0" } else { created_raw }
|
|
let eff_updated: String = if str_eq(updated_raw, "") { eff_created } else { updated_raw }
|
|
let entry: String = if is_session && !str_eq(sess_id, "") {
|
|
"{\"id\":\"" + json_safe(sess_id) + "\""
|
|
+ ",\"title\":\"" + json_safe(title) + "\""
|
|
+ ",\"created_at\":" + eff_created
|
|
+ ",\"updated_at\":" + eff_updated + "}"
|
|
} else { "" }
|
|
let out = if !str_eq(entry, "") {
|
|
if str_eq(out, "") { entry } else { out + "," + entry }
|
|
} else { out }
|
|
let i = i + 1
|
|
}
|
|
return "[" + out + "]"
|
|
}
|
|
|
|
// session_hist_load — load a session's message history from state or engram.
|
|
fn session_hist_load(session_id: String) -> String {
|
|
let state_hist: String = state_get("session_hist_" + session_id)
|
|
if !str_eq(state_hist, "") { return state_hist }
|
|
// Try engram fallback
|
|
let results: String = engram_search_json("session:messages:" + session_id, 3)
|
|
if str_eq(results, "") { return "" }
|
|
if str_eq(results, "[]") { return "" }
|
|
let node: String = json_array_get(results, 0)
|
|
let label: String = json_get(node, "label")
|
|
if !str_eq(label, "session:messages:" + session_id) { return "" }
|
|
let content: String = json_get(node, "content")
|
|
if str_starts_with(content, "[") { return content }
|
|
return ""
|
|
}
|
|
|
|
// 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)
|
|
// 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) }
|
|
let oi: Int = 0
|
|
while oi < o_total {
|
|
let node: String = json_array_get(old_results, oi)
|
|
let label: String = json_get(node, "label")
|
|
let nid: String = json_get(node, "id")
|
|
if str_eq(label, "session:messages:" + session_id) && !str_eq(nid, "") {
|
|
engram_forget(nid)
|
|
}
|
|
let oi = oi + 1
|
|
}
|
|
let tags: String = "[\"session\",\"session-history\",\"Conversation\"]"
|
|
let discard: String = engram_node_full(
|
|
hist, "Conversation", "session:messages:" + session_id,
|
|
el_from_float(0.6), el_from_float(0.6), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
}
|
|
|
|
// session_update_meta_timestamp — update the updated_at field in the session:meta node.
|
|
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) }
|
|
let found: Bool = false
|
|
let old_title: String = "New conversation"
|
|
let old_folder: String = ""
|
|
let old_created: String = "0"
|
|
let old_node_id: String = ""
|
|
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) && !found
|
|
let found = if is_match { true } else { found }
|
|
let title_raw: String = json_get(content, "title")
|
|
let old_title = if is_match && !str_eq(title_raw, "") { title_raw } else { old_title }
|
|
let folder_raw: String = json_get(content, "folder")
|
|
let old_folder = if is_match { folder_raw } else { old_folder }
|
|
let created_raw: String = json_get(content, "created_at")
|
|
let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created }
|
|
let nid: String = json_get(node, "id")
|
|
let old_node_id = if is_match { nid } else { old_node_id }
|
|
let i = i + 1
|
|
}
|
|
if !found { return "" }
|
|
if !str_eq(old_node_id, "") {
|
|
engram_forget(old_node_id)
|
|
}
|
|
let ts: Int = time_now()
|
|
let created_int: Int = str_to_int(old_created)
|
|
let new_content: String = session_make_content(session_id, old_title, created_int, ts, old_folder)
|
|
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
|
let new_id: String = engram_node_full(
|
|
new_content, "Conversation", "session:meta",
|
|
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
state_set("session_node_" + session_id, new_id)
|
|
}
|
|
|
|
// session_auto_title — if the session title is still "New conversation", update it
|
|
// using the first user message.
|
|
fn session_auto_title(session_id: String, first_message: 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) }
|
|
let found: Bool = false
|
|
let cur_title: String = ""
|
|
let old_folder: String = ""
|
|
let old_created: String = "0"
|
|
let old_node_id: String = ""
|
|
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) && !found
|
|
let found = if is_match { true } else { found }
|
|
let title_raw: String = json_get(content, "title")
|
|
let cur_title = if is_match { title_raw } else { cur_title }
|
|
let folder_raw: String = json_get(content, "folder")
|
|
let old_folder = if is_match { folder_raw } else { old_folder }
|
|
let created_raw: String = json_get(content, "created_at")
|
|
let old_created = if is_match && !str_eq(created_raw, "") { created_raw } else { old_created }
|
|
let nid: String = json_get(node, "id")
|
|
let old_node_id = if is_match { nid } else { old_node_id }
|
|
let i = i + 1
|
|
}
|
|
if !found { return "" }
|
|
if !str_eq(cur_title, "New conversation") { return "" }
|
|
// Update title, preserve folder
|
|
let new_title: String = session_title_from_message(first_message)
|
|
if !str_eq(old_node_id, "") {
|
|
engram_forget(old_node_id)
|
|
}
|
|
let ts: Int = time_now()
|
|
let created_int: Int = str_to_int(old_created)
|
|
let new_content: String = session_make_content(session_id, new_title, created_int, ts, old_folder)
|
|
let tags: String = "[\"session\",\"session:meta\",\"Conversation\"]"
|
|
let new_id: String = engram_node_full(
|
|
new_content, "Conversation", "session:meta",
|
|
el_from_float(0.7), el_from_float(0.7), el_from_float(0.9),
|
|
"Episodic", tags
|
|
)
|
|
state_set("session_node_" + session_id, new_id)
|
|
}
|
|
|
|
// handle_session_approve — handle tool approval for a pending agentic tool call.
|
|
// action: "allow" | "deny" | "always"
|
|
// Resumes the agentic loop from where it was paused.
|
|
//
|
|
// 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()
|
|
// which re-enters agentic_loop from exactly the right point.
|
|
//
|
|
// Legacy path (pending_tool_<session_id>): used by any in-flight sessions that
|
|
// were suspended by the old inline loop before a deploy. Kept so those sessions
|
|
// are not broken during a rolling restart.
|
|
fn handle_session_approve(session_id: String, body: String) -> String {
|
|
if str_eq(session_id, "") {
|
|
return "{\"error\":\"session_id is required\"}"
|
|
}
|
|
let call_id: String = json_get(body, "call_id")
|
|
let action: String = json_get(body, "action")
|
|
if str_eq(call_id, "") {
|
|
return "{\"error\":\"call_id is required\"}"
|
|
}
|
|
if str_eq(action, "") {
|
|
return "{\"error\":\"action is required (allow|deny|always)\"}"
|
|
}
|
|
|
|
let eff_action: String = if str_eq(action, "always") { "allow" } else { action }
|
|
|
|
// ── Modern path: suspension is in mcp_bridge:<session_id> ──────────────
|
|
// agentic_loop (chat.el) writes here via bridge_save(). This is the primary
|
|
// path for all sessions created through handle_chat_agentic / agentic_loop.
|
|
let bridge_blob: String = state_get("mcp_bridge:" + session_id)
|
|
if !str_eq(bridge_blob, "") {
|
|
// For "always": record tool_name in the always-allow list before resuming.
|
|
// The tool_name is not stored in the bridge blob (only tool_use_id is).
|
|
// Accept it from the body so the client can pass it along.
|
|
let always_key: String = "always_allow_" + session_id
|
|
let approve_tool_name: String = json_get(body, "tool_name")
|
|
let discard_always: Bool = if str_eq(action, "always") && !str_eq(approve_tool_name, "") {
|
|
let always_list: String = state_get(always_key)
|
|
let new_always: String = if str_eq(always_list, "") { approve_tool_name }
|
|
else { always_list + "," + approve_tool_name }
|
|
state_set(always_key, new_always)
|
|
true
|
|
} else { false }
|
|
|
|
// BLOCKER: tool_name is required for allow — an empty approve_tool_name
|
|
// would cause dispatch_tool("", ...) to silently return "unknown tool: "
|
|
// and inject a corrupted result into the conversation. Reject early.
|
|
if str_eq(approve_tool_name, "") && str_eq(eff_action, "allow") {
|
|
return "{\"error\":\"tool_name is required for allow action\"}"
|
|
}
|
|
|
|
// Build the content string the tool produced (or the denial message).
|
|
//
|
|
// For MCP/client-side tools (non-builtin): the client has ALREADY executed
|
|
// the tool and posts the result in body["content"]. Accept it directly
|
|
// (matching the handle_tool_result contract) rather than re-running
|
|
// server-side via dispatch_tool — that would make the client-side execution
|
|
// irrelevant and would break mcp__* tools the soul cannot reach.
|
|
//
|
|
// For builtin tools with no client-provided content: fall back to
|
|
// dispatch_tool so those tools still execute correctly.
|
|
let client_content: String = json_get(body, "content")
|
|
let use_client_content: Bool = !str_eq(client_content, "")
|
|
let use_dispatch: Bool = is_builtin_tool(approve_tool_name) && !use_client_content
|
|
let raw_input: String = json_get_raw(body, "tool_input")
|
|
let eff_input: String = if str_eq(raw_input, "") { "{}" } else { raw_input }
|
|
let content: String = if str_eq(eff_action, "allow") {
|
|
if use_client_content {
|
|
let trimmed: String = if str_len(client_content) > 6000 {
|
|
str_slice(client_content, 0, 6000) + "...[truncated]"
|
|
} else { client_content }
|
|
trimmed
|
|
} else if use_dispatch {
|
|
let raw: String = dispatch_tool(approve_tool_name, eff_input)
|
|
if str_len(raw) > 6000 { str_slice(raw, 0, 6000) + "...[truncated]" } else { raw }
|
|
} else {
|
|
// Non-builtin tool, no client content — error rather than
|
|
// silently dispatching a tool the soul cannot execute.
|
|
"{\"error\":\"client content required for non-builtin tool: " + approve_tool_name + "\"}"
|
|
}
|
|
} else {
|
|
"{\"error\":\"User denied this tool call\"}"
|
|
}
|
|
|
|
return agentic_resume(session_id, call_id, content)
|
|
}
|
|
|
|
// ── Legacy path: suspension is in pending_tool_<session_id> ────────────
|
|
// Kept for in-flight sessions that were suspended before a deploy.
|
|
let pending_raw: String = state_get("pending_tool_" + session_id)
|
|
if str_eq(pending_raw, "") {
|
|
return "{\"error\":\"no pending tool for session\",\"session_id\":\"" + session_id + "\"}"
|
|
}
|
|
|
|
let pending_call_id: String = json_get(pending_raw, "call_id")
|
|
if !str_eq(pending_call_id, call_id) {
|
|
return "{\"error\":\"call_id mismatch\",\"expected\":\"" + pending_call_id + "\"}"
|
|
}
|
|
|
|
let tool_name: String = json_get(pending_raw, "tool_name")
|
|
let tool_input: String = json_get_raw(pending_raw, "tool_input")
|
|
let model: String = json_get(pending_raw, "model")
|
|
let safe_sys: String = json_get(pending_raw, "system")
|
|
|
|
// For "always": add to always-allow list
|
|
let always_key: String = "always_allow_" + session_id
|
|
let always_list: String = state_get(always_key)
|
|
let discard_always2: Bool = if str_eq(action, "always") {
|
|
let new_always: String = if str_eq(always_list, "") { tool_name }
|
|
else { always_list + "," + tool_name }
|
|
state_set(always_key, new_always)
|
|
true
|
|
} else { false }
|
|
|
|
// Clear pending state
|
|
state_set("pending_tool_" + session_id, "")
|
|
|
|
// Build tool result
|
|
let tool_result: String = if str_eq(eff_action, "allow") {
|
|
let raw: String = dispatch_tool(tool_name, tool_input)
|
|
if str_len(raw) > 6000 { str_slice(raw, 0, 6000) + "...[truncated]" } else { raw }
|
|
} else {
|
|
"{\"error\":\"User denied this tool call\"}"
|
|
}
|
|
|
|
// Legacy sessions stored messages_so_far; synthesise a bridge blob so the
|
|
// same agentic_resume path handles continuation (instead of an inline loop).
|
|
// messages_so_far already includes the assistant turn that requested the tool.
|
|
let legacy_messages: String = json_get_raw(pending_raw, "messages_so_far")
|
|
// WARNING: the original session may have used agentic_tools_with_web() or
|
|
// agentic_tools_all(). The old pending blob did not store the tools variant.
|
|
// Read a "tools_variant" field if present (future suspensions record it);
|
|
// fall back to agentic_tools_literal() for legacy blobs that lack this field.
|
|
let stored_variant: String = json_get(pending_raw, "tools_variant")
|
|
let tools_json: String = if str_eq(stored_variant, "web") { agentic_tools_with_web() }
|
|
else if str_eq(stored_variant, "all") { agentic_tools_all() }
|
|
else { agentic_tools_literal() }
|
|
|
|
// Write a synthetic bridge blob so agentic_resume can pick it up.
|
|
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
|
|
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
|
|
+ ",\"tools_json\":\"" + json_safe(tools_json) + "\""
|
|
+ ",\"messages\":\"" + json_safe(legacy_messages) + "\""
|
|
+ ",\"tools_log\":\"\""
|
|
+ ",\"tool_use_id\":\"" + json_safe(call_id) + "\"}"
|
|
state_set("mcp_bridge:" + session_id, blob)
|
|
|
|
return agentic_resume(session_id, call_id, tool_result)
|
|
}
|