Compare commits

...

1 Commits

Author SHA1 Message Date
will.anderson 7c7dc310a0 fix(sessions): unify dual suspension systems in handle_session_approve
Neuron Soul CI / build (pull_request) Failing after 11m26s
The approve endpoint was permanently broken for all sessions going through
the modern agentic_loop path. agentic_loop suspends via bridge_save() into
mcp_bridge:<session_id>, but handle_session_approve was reading from
pending_tool_<session_id> — a different key — so it always returned
"no pending tool for session".

Replace the body of handle_session_approve with a two-path design:

Modern path: check mcp_bridge:<session_id> first. If the blob is there,
dispatch_tool() on allow (or build the denial string), then delegate to
agentic_resume() which re-enters agentic_loop from the exact suspension
point. This is the path all live sessions take.

Legacy path: if only pending_tool_<session_id> exists (in-flight session
from before this deploy), synthesise a bridge blob from the stored
messages_so_far and route through agentic_resume() as well. The stale
inline agentic loop (90 lines, agentic_tools_literal only, no MCP
connector support, no bridge suspension) is removed entirely.

routes.el already calls handle_session_approve correctly — no change needed.
2026-06-15 13:03:15 -05:00
+60 -146
View File
@@ -461,6 +461,15 @@ fn session_auto_title(session_id: String, first_message: String) -> Void {
// 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\"}"
@@ -474,7 +483,42 @@ fn handle_session_approve(session_id: String, body: String) -> String {
return "{\"error\":\"action is required (allow|deny|always)\"}"
}
// Load the pending tool state
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 }
// Build the content string the tool produced (or the denial message).
// tool_input may be passed in the body by the client for re-execution.
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") {
let raw: String = dispatch_tool(approve_tool_name, eff_input)
if str_len(raw) > 6000 { str_slice(raw, 0, 6000) + "...[truncated]" } else { raw }
} 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 + "\"}"
@@ -487,14 +531,13 @@ fn handle_session_approve(session_id: String, body: String) -> String {
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 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)
@@ -504,157 +547,28 @@ fn handle_session_approve(session_id: String, body: String) -> String {
// 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\"}")
"{\"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()
// 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")
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
// 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)
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 + "\"}"
return agentic_resume(session_id, call_id, tool_result)
}