00f15b094b
- sessions.el: new sessions module with session management and approval gate
- routes.el: wire /api/sessions routes (list, get, create, approve, tool_result)
- chat.el: thread-aware activation — short messages anchor to last reply
before engram compilation so follow-ups stay on-topic
- chat.el: agentic path tracks per-session history (session_hist_{id})
instead of shared conv_history, seeding each turn with prior context
- chat.el: add call_neuron_mcp, dispatch_tool, is_builtin_tool, next_bridge_id
agentic_loop, bridge_save, agentic_resume, handle_tool_result
- dist/soul: rebuild with all of the above
661 lines
30 KiB
EmacsLisp
661 lines
30 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
|
|
state_set("session_hist_" + session_id, "")
|
|
state_set("session_node_" + 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.
|
|
// 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.
|
|
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)\"}"
|
|
}
|
|
|
|
// Load the pending tool state
|
|
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 messages: String = json_get_raw(pending_raw, "messages_so_far")
|
|
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_always: 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, "")
|
|
|
|
let eff_action: String = if str_eq(action, "always") { "allow" } else { action }
|
|
|
|
// 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 {
|
|
json_safe("{\"error\":\"User denied this tool call\"}")
|
|
}
|
|
|
|
let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + call_id + "\",\"content\":\"" + tool_result + "\"}"
|
|
|
|
// Reconstruct messages with the tool result appended
|
|
// messages_so_far is the messages array at the point of the tool call
|
|
// We need to append a user turn with the tool result and re-enter the loop
|
|
let inner: String = str_slice(messages, 1, str_len(messages) - 1)
|
|
let resumed_messages: String = "[" + inner + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
|
|
|
|
// Re-enter the agentic loop with the resumed messages
|
|
let api_key: String = agentic_api_key()
|
|
let tools_json: String = agentic_tools_literal()
|
|
let api_url: String = "https://api.anthropic.com/v1/messages"
|
|
let h: Map = {}
|
|
map_set(h, "x-api-key", api_key)
|
|
map_set(h, "anthropic-version", "2023-06-01")
|
|
map_set(h, "content-type", "application/json")
|
|
|
|
let final_text: String = ""
|
|
let tools_log: String = ""
|
|
let iteration: Int = 0
|
|
let keep_going: Bool = true
|
|
let cur_messages: String = resumed_messages
|
|
|
|
while keep_going && iteration < 8 {
|
|
let req_body: String = "{\"model\":\"" + model + "\""
|
|
+ ",\"max_tokens\":4096"
|
|
+ ",\"system\":\"" + safe_sys + "\""
|
|
+ ",\"tools\":" + tools_json
|
|
+ ",\"messages\":" + cur_messages
|
|
+ "}"
|
|
|
|
let raw_resp: String = http_post_with_headers(api_url, req_body, h)
|
|
|
|
let is_error: Bool = str_starts_with(raw_resp, "{\"error\"")
|
|
|| str_starts_with(raw_resp, "{\"type\":\"error\"")
|
|
|| str_contains(raw_resp, "authentication_error")
|
|
if is_error {
|
|
return "{\"error\":\"llm unavailable\",\"reply\":\"\"}"
|
|
}
|
|
|
|
let stop_reason: String = json_get(raw_resp, "stop_reason")
|
|
let content_arr: String = json_get_raw(raw_resp, "content")
|
|
let eff_content: String = if str_eq(content_arr, "") { "[]" } else { content_arr }
|
|
|
|
let text_out: String = ""
|
|
let has_tool: Bool = false
|
|
let next_tool_id: String = ""
|
|
let next_tool_name: String = ""
|
|
let next_tool_input: String = ""
|
|
let ci: Int = 0
|
|
let c_total: Int = json_array_len(eff_content)
|
|
while ci < c_total {
|
|
let block: String = json_array_get(eff_content, ci)
|
|
let btype: String = json_get(block, "type")
|
|
let text_out = if str_eq(btype, "text") { text_out + json_get(block, "text") } else { text_out }
|
|
let is_new_tool: Bool = str_eq(btype, "tool_use") && !has_tool
|
|
let has_tool = if is_new_tool { true } else { has_tool }
|
|
let next_tool_id = if is_new_tool { json_get(block, "id") } else { next_tool_id }
|
|
let next_tool_name = if is_new_tool { json_get(block, "name") } else { next_tool_name }
|
|
let next_tool_input = if is_new_tool { json_get_raw(block, "input") } else { next_tool_input }
|
|
let ci = ci + 1
|
|
}
|
|
|
|
let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool
|
|
let inner2: String = str_slice(cur_messages, 1, str_len(cur_messages) - 1)
|
|
|
|
// Check if this next tool is in the always-allow list
|
|
let always_list2: String = state_get(always_key)
|
|
let is_always: Bool = str_contains(always_list2, next_tool_name) && !str_eq(next_tool_name, "")
|
|
|
|
// For approval-required sessions, pause on tool use if not always-allowed
|
|
let require_approval: String = state_get("session_require_approval_" + session_id)
|
|
let needs_pause: Bool = is_tool_turn && str_eq(require_approval, "true") && !is_always
|
|
|
|
let next_tool_result: String = if is_tool_turn && !needs_pause {
|
|
let raw2: String = dispatch_tool(next_tool_name, next_tool_input)
|
|
if str_len(raw2) > 6000 { str_slice(raw2, 0, 6000) + "...[truncated]" } else { raw2 }
|
|
} else { "" }
|
|
|
|
let next_tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + next_tool_id + "\",\"content\":\"" + next_tool_result + "\"}"
|
|
let tool_entry: String = "{\"tool\":\"" + next_tool_name + "\",\"input\":\"" + json_safe(next_tool_name) + "\"}"
|
|
let tools_log = if is_tool_turn && !needs_pause {
|
|
if str_eq(tools_log, "") { tool_entry } else { tools_log + "," + tool_entry }
|
|
} else { tools_log }
|
|
|
|
let cur_messages = if is_tool_turn && !needs_pause {
|
|
"[" + inner2
|
|
+ ",{\"role\":\"assistant\",\"content\":" + eff_content + "}"
|
|
+ ",{\"role\":\"user\",\"content\":[" + next_tool_msg + "]}"
|
|
+ "]"
|
|
} else { cur_messages }
|
|
|
|
// Pause if approval needed for next tool
|
|
let discard_pause: Bool = if needs_pause {
|
|
let safe_sys2: String = json_safe(safe_sys)
|
|
let msgs_with_assistant: String = "[" + inner2
|
|
+ ",{\"role\":\"assistant\",\"content\":" + eff_content + "}]"
|
|
let pending: String = "{\"call_id\":\"" + next_tool_id + "\""
|
|
+ ",\"tool_name\":\"" + next_tool_name + "\""
|
|
+ ",\"tool_input\":" + next_tool_input
|
|
+ ",\"messages_so_far\":" + msgs_with_assistant
|
|
+ ",\"model\":\"" + model + "\""
|
|
+ ",\"system\":\"" + safe_sys2 + "\"}"
|
|
state_set("pending_tool_" + session_id, pending)
|
|
true
|
|
} else { false }
|
|
|
|
let final_text = if !is_tool_turn { text_out } else { final_text }
|
|
let keep_going = if !is_tool_turn { false } else {
|
|
if needs_pause { false } else { keep_going }
|
|
}
|
|
let iteration = iteration + 1
|
|
}
|
|
|
|
// Check if we paused on a new tool
|
|
let new_pending: String = state_get("pending_tool_" + session_id)
|
|
if !str_eq(new_pending, "") {
|
|
let np_tool_name: String = json_get(new_pending, "tool_name")
|
|
let np_call_id: String = json_get(new_pending, "call_id")
|
|
let np_tool_input: String = json_get_raw(new_pending, "tool_input")
|
|
return "{\"status\":\"tool_pending\""
|
|
+ ",\"call_id\":\"" + np_call_id + "\""
|
|
+ ",\"tool_name\":\"" + np_tool_name + "\""
|
|
+ ",\"tool_input\":" + np_tool_input
|
|
+ ",\"session_id\":\"" + session_id + "\"}"
|
|
}
|
|
|
|
if str_eq(final_text, "") {
|
|
return "{\"error\":\"no response after approval\",\"reply\":\"\"}"
|
|
}
|
|
|
|
// Save updated history
|
|
let hist: String = session_hist_load(session_id)
|
|
let updated_hist: String = hist_append(hist, "assistant", final_text)
|
|
let final_hist: String = if json_array_len(updated_hist) > 20 {
|
|
hist_trim(updated_hist)
|
|
} else { updated_hist }
|
|
session_hist_save(session_id, final_hist)
|
|
session_update_meta_timestamp(session_id)
|
|
|
|
let safe_text: String = json_safe(final_text)
|
|
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
|
|
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + ",\"session_id\":\"" + session_id + "\"}"
|
|
}
|