Files
neuron/sessions.el
T
will.anderson fc74bd2a4b
Deploy Soul to GKE / deploy (push) Failing after 6m35s
Neuron Soul CI / build (push) Failing after 14m31s
Merge pull request 'fix(sessions): unify dual suspension systems, wire approve to agentic_resume' (#18) from fix/agentic-tool-approval-unification into main
fix(sessions): unify dual suspension systems, wire approve to agentic_resume
2026-06-17 18:06:01 +00:00

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)
}