fix(chat): thread-aware activation for conversation continuity
Short/ambiguous messages (< 50 chars) now use the last reply as the engram activation seed instead of the bare message. Prevents strong off-topic memory nodes from hijacking replies when the user is clearly continuing an existing thread. Also gives handle_chat_agentic session continuity: reads/writes history keyed by session_id (falling back to global conv_history), seeds the LLM messages array with prior turns, and saves replies back so the next turn has context.
This commit is contained in:
@@ -156,13 +156,27 @@ fn handle_chat(body: String) -> String {
|
||||
return "{\"error\":\"message is required\",\"response\":\"\"}"
|
||||
}
|
||||
|
||||
let ctx: String = engram_compile(message)
|
||||
let system: String = build_system_prompt(ctx)
|
||||
|
||||
// Load from state; if empty, try to recover from engram (cross-restart continuity)
|
||||
// Load history BEFORE compiling context so we can anchor activation to the thread.
|
||||
let state_hist: String = state_get("conv_history")
|
||||
let stored_hist: String = if str_eq(state_hist, "") { conv_history_load() } else { state_hist }
|
||||
let hist_len: Int = if str_eq(stored_hist, "") { 0 } else { json_array_len(stored_hist) }
|
||||
|
||||
// Thread-aware activation: short/ambiguous messages (continuations like "go on",
|
||||
// "what else?", "yes") activate on the last reply instead of the bare message.
|
||||
// This prevents a strong off-topic memory node from hijacking the reply when the
|
||||
// user is clearly continuing an existing thread.
|
||||
let is_continuation: Bool = str_len(message) < 50 && hist_len > 0
|
||||
let last_entry: String = if is_continuation { json_array_get(stored_hist, hist_len - 1) } else { "" }
|
||||
let last_content: String = if !str_eq(last_entry, "") { json_get(last_entry, "content") } else { "" }
|
||||
let thread_snip: String = if str_len(last_content) > 150 { str_slice(last_content, 0, 150) } else { last_content }
|
||||
let activation_seed: String = if !str_eq(thread_snip, "") {
|
||||
thread_snip + " " + message
|
||||
} else {
|
||||
message
|
||||
}
|
||||
|
||||
let ctx: String = engram_compile(activation_seed)
|
||||
let system: String = build_system_prompt(ctx)
|
||||
let full_system: String = if hist_len > 0 {
|
||||
system + "\n\n[RECENT CONVERSATION — last " + int_to_str(hist_len) + " turns]\n" + stored_hist
|
||||
} else {
|
||||
@@ -255,7 +269,18 @@ fn agentic_tools_literal() -> String {
|
||||
"{\"name\":\"write_file\",\"description\":\"Write content to a file on disk.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"content\":{\"type\":\"string\"}},\"required\":[\"path\",\"content\"]}}," +
|
||||
"{\"name\":\"web_get\",\"description\":\"Fetch content from a URL.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"url\":{\"type\":\"string\"}},\"required\":[\"url\"]}}," +
|
||||
"{\"name\":\"search_memory\",\"description\":\"Search engram memory for relevant nodes.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"]}}," +
|
||||
"{\"name\":\"run_command\",\"description\":\"Run a shell command and capture output.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}},\"required\":[\"command\"]}}" +
|
||||
"{\"name\":\"run_command\",\"description\":\"Run a shell command and capture output.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"command\":{\"type\":\"string\"}},\"required\":[\"command\"]}}," +
|
||||
"{\"name\":\"list_files\",\"description\":\"List files in a directory.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"}},\"required\":[\"path\"]}}," +
|
||||
"{\"name\":\"grep\",\"description\":\"Search for a pattern in files.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"pattern\":{\"type\":\"string\"},\"path\":{\"type\":\"string\"}},\"required\":[\"pattern\",\"path\"]}}," +
|
||||
"{\"name\":\"edit_file\",\"description\":\"Edit a file by replacing old_text with new_text.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\"},\"old_text\":{\"type\":\"string\"},\"new_text\":{\"type\":\"string\"}},\"required\":[\"path\",\"old_text\",\"new_text\"]}}," +
|
||||
"{\"name\":\"remember\",\"description\":\"Store a memory in the Engram graph.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"content\":{\"type\":\"string\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}}},\"required\":[\"content\"]}}," +
|
||||
"{\"name\":\"recall\",\"description\":\"Recall memories by activating the Engram graph from a query.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"depth\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," +
|
||||
"{\"name\":\"neuron_search_knowledge\",\"description\":\"Search Neuron's knowledge base.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," +
|
||||
"{\"name\":\"neuron_remember\",\"description\":\"Store a memory in Neuron's persistent graph.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"content\":{\"type\":\"string\"},\"tags\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"project\":{\"type\":\"string\"},\"importance\":{\"type\":\"string\"}},\"required\":[\"content\"]}}," +
|
||||
"{\"name\":\"neuron_recall\",\"description\":\"Search Neuron's memory nodes.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"limit\":{\"type\":\"integer\"}},\"required\":[\"query\"]}}," +
|
||||
"{\"name\":\"neuron_review_backlog\",\"description\":\"Review Neuron's work backlog.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"view\":{\"type\":\"string\"},\"project\":{\"type\":\"string\"},\"status\":{\"type\":\"string\"},\"priority\":{\"type\":\"string\"},\"query\":{\"type\":\"string\"}},\"required\":[]}}," +
|
||||
"{\"name\":\"neuron_find_artifacts\",\"description\":\"Find Neuron artifacts by project or query.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"},\"project\":{\"type\":\"string\"}},\"required\":[]}}," +
|
||||
"{\"name\":\"neuron_compile_ctx\",\"description\":\"Compile Neuron's full active context snapshot.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"required\":[]}}" +
|
||||
"]"
|
||||
}
|
||||
|
||||
@@ -334,6 +359,24 @@ fn tool_auto_approved(tool_name: String) -> Bool {
|
||||
return str_contains(list, "\"" + tool_name + "\"")
|
||||
}
|
||||
|
||||
// call_neuron_mcp — proxy a Neuron MCP tool call to the mcp-proxy on :7779.
|
||||
// The proxy speaks the Neuron MCP wire protocol; we speak flat HTTP + JSON.
|
||||
fn call_neuron_mcp(tool_name: String, args: String) -> String {
|
||||
let body: String = "{\"tool\":\"" + tool_name + "\",\"args\":" + args + "}"
|
||||
let tmp: String = "/tmp/neuron-mcp-neuron-call.json"
|
||||
fs_write(tmp, body)
|
||||
let raw: String = exec_capture("curl -s --max-time 10 -X POST http://127.0.0.1:7779/mcp/call -H 'Content-Type: application/json' -d @" + tmp)
|
||||
if str_eq(raw, "") {
|
||||
return json_safe("{\"error\":\"Neuron MCP unreachable\"}")
|
||||
}
|
||||
let result: String = json_get(raw, "result")
|
||||
if str_eq(result, "") {
|
||||
let err: String = json_get(raw, "error")
|
||||
return json_safe(if str_eq(err, "") { "Neuron MCP call failed" } else { "Neuron MCP error: " + err })
|
||||
}
|
||||
return json_safe(result)
|
||||
}
|
||||
|
||||
fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
if str_eq(tool_name, "read_file") {
|
||||
let path: String = json_get(tool_input, "path")
|
||||
@@ -376,6 +419,104 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
}
|
||||
return json_safe(content)
|
||||
}
|
||||
if str_eq(tool_name, "list_files") {
|
||||
let path: String = json_get(tool_input, "path")
|
||||
let result: String = exec_capture("ls -la " + path + " 2>&1")
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "grep") {
|
||||
let pattern: String = json_get(tool_input, "pattern")
|
||||
let path: String = json_get(tool_input, "path")
|
||||
let result: String = exec_capture("grep -rn \"" + pattern + "\" " + path + " 2>&1 | head -50")
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "edit_file") {
|
||||
let path: String = json_get(tool_input, "path")
|
||||
let old_text: String = json_get(tool_input, "old_text")
|
||||
let new_text: String = json_get(tool_input, "new_text")
|
||||
let content: String = fs_read(path)
|
||||
if str_eq(content, "") {
|
||||
return json_safe("{\"error\":\"file not found\"}")
|
||||
}
|
||||
let updated: String = str_replace(content, old_text, new_text)
|
||||
fs_write(path, updated)
|
||||
return json_safe("{\"ok\":true}")
|
||||
}
|
||||
if str_eq(tool_name, "remember") {
|
||||
let content: String = json_get(tool_input, "content")
|
||||
let tags_raw: String = json_get(tool_input, "tags")
|
||||
let tags: String = if str_eq(tags_raw, "") { "[\"chat\"]" } else { tags_raw }
|
||||
let id: String = mem_remember(content, tags)
|
||||
return json_safe("{\"ok\":true,\"id\":\"" + id + "\"}")
|
||||
}
|
||||
if str_eq(tool_name, "recall") {
|
||||
let query: String = json_get(tool_input, "query")
|
||||
let depth_str: String = json_get(tool_input, "depth")
|
||||
let depth: Int = if str_eq(depth_str, "") { 3 } else { str_to_int(depth_str) }
|
||||
let result: String = mem_recall(query, depth)
|
||||
return json_safe(result)
|
||||
}
|
||||
// ── Neuron MCP tools (shared knowledge graph at 127.0.0.1:7779) ──────────
|
||||
if str_eq(tool_name, "neuron_search_knowledge") {
|
||||
let query: String = json_get(tool_input, "query")
|
||||
let limit_str: String = json_get(tool_input, "limit")
|
||||
let limit: Int = if str_eq(limit_str, "") { 5 } else { str_to_int(limit_str) }
|
||||
let args: String = "{\"query\":\"" + json_safe(query) + "\",\"limit\":" + int_to_str(limit) + "}"
|
||||
let result: String = call_neuron_mcp("searchKnowledge", args)
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "neuron_remember") {
|
||||
let content: String = json_get(tool_input, "content")
|
||||
let tags_raw: String = json_get_raw(tool_input, "tags")
|
||||
let project: String = json_get(tool_input, "project")
|
||||
let importance: String = json_get(tool_input, "importance")
|
||||
let safe_content: String = json_safe(content)
|
||||
let tags_part: String = if str_eq(tags_raw, "") { "\"tags\":[\"chat\"]" } else { "\"tags\":" + tags_raw }
|
||||
let project_part: String = if str_eq(project, "") { "" } else { ",\"project\":\"" + json_safe(project) + "\"" }
|
||||
let importance_part: String = if str_eq(importance, "") { "" } else { ",\"importance\":\"" + json_safe(importance) + "\"" }
|
||||
let args: String = "{\"content\":\"" + safe_content + "\"," + tags_part + project_part + importance_part + "}"
|
||||
let result: String = call_neuron_mcp("remember", args)
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "neuron_recall") {
|
||||
let query: String = json_get(tool_input, "query")
|
||||
let limit_str: String = json_get(tool_input, "limit")
|
||||
let limit: Int = if str_eq(limit_str, "") { 10 } else { str_to_int(limit_str) }
|
||||
let args: String = "{\"query\":\"" + json_safe(query) + "\",\"limit\":" + int_to_str(limit) + "}"
|
||||
let result: String = call_neuron_mcp("inspectMemories", args)
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "neuron_review_backlog") {
|
||||
let view: String = json_get(tool_input, "view")
|
||||
let project: String = json_get(tool_input, "project")
|
||||
let status: String = json_get(tool_input, "status")
|
||||
let priority: String = json_get(tool_input, "priority")
|
||||
let query: String = json_get(tool_input, "query")
|
||||
let view_part: String = if str_eq(view, "") { "\"view\":\"roadmap\"" } else { "\"view\":\"" + json_safe(view) + "\"" }
|
||||
let project_part: String = if str_eq(project, "") { "" } else { ",\"project\":\"" + json_safe(project) + "\"" }
|
||||
let status_part: String = if str_eq(status, "") { "" } else { ",\"status\":\"" + json_safe(status) + "\"" }
|
||||
let priority_part: String = if str_eq(priority, "") { "" } else { ",\"priority\":\"" + json_safe(priority) + "\"" }
|
||||
let query_part: String = if str_eq(query, "") { "" } else { ",\"query\":\"" + json_safe(query) + "\"" }
|
||||
let args: String = "{" + view_part + project_part + status_part + priority_part + query_part + "}"
|
||||
let result: String = call_neuron_mcp("reviewBacklog", args)
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "neuron_find_artifacts") {
|
||||
let query: String = json_get(tool_input, "query")
|
||||
let project: String = json_get(tool_input, "project")
|
||||
let query_part: String = if str_eq(query, "") { "" } else { "\"query\":\"" + json_safe(query) + "\"" }
|
||||
let project_part: String = if str_eq(project, "") { "" } else {
|
||||
if str_eq(query_part, "") { "\"project\":\"" + json_safe(project) + "\"" }
|
||||
else { ",\"project\":\"" + json_safe(project) + "\"" }
|
||||
}
|
||||
let args: String = "{" + query_part + project_part + "}"
|
||||
let result: String = call_neuron_mcp("findArtifacts", args)
|
||||
return json_safe(result)
|
||||
}
|
||||
if str_eq(tool_name, "neuron_compile_ctx") {
|
||||
let result: String = call_neuron_mcp("compileCtx", "{}")
|
||||
return json_safe(result)
|
||||
}
|
||||
return "unknown tool: " + tool_name
|
||||
}
|
||||
|
||||
@@ -390,6 +531,12 @@ fn is_builtin_tool(tool_name: String) -> Bool {
|
||||
|| str_eq(tool_name, "web_get")
|
||||
|| str_eq(tool_name, "search_memory")
|
||||
|| str_eq(tool_name, "run_command")
|
||||
|| str_eq(tool_name, "list_files")
|
||||
|| str_eq(tool_name, "grep")
|
||||
|| str_eq(tool_name, "edit_file")
|
||||
|| str_eq(tool_name, "remember")
|
||||
|| str_eq(tool_name, "recall")
|
||||
|| str_starts_with(tool_name, "neuron_")
|
||||
}
|
||||
|
||||
// next_bridge_id — monotonic correlation id for a suspended agentic turn.
|
||||
@@ -412,7 +559,19 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
let req_model: String = json_get(body, "model")
|
||||
let model: String = if str_eq(req_model, "") { chat_default_model() } else { req_model }
|
||||
|
||||
let ctx: String = engram_compile(message)
|
||||
// Thread-aware activation: same logic as handle_chat.
|
||||
// Use the session's or global history to anchor short messages to the thread.
|
||||
let req_session: String = json_get(body, "session_id")
|
||||
let hist_key: String = if str_eq(req_session, "") { "conv_history" } else { "session_hist_" + req_session }
|
||||
let agentic_hist: String = state_get(hist_key)
|
||||
let agentic_hist_len: Int = if str_eq(agentic_hist, "") { 0 } else { json_array_len(agentic_hist) }
|
||||
let ag_is_cont: Bool = str_len(message) < 50 && agentic_hist_len > 0
|
||||
let ag_last_entry: String = if ag_is_cont { json_array_get(agentic_hist, agentic_hist_len - 1) } else { "" }
|
||||
let ag_last_content: String = if !str_eq(ag_last_entry, "") { json_get(ag_last_entry, "content") } else { "" }
|
||||
let ag_thread_snip: String = if str_len(ag_last_content) > 150 { str_slice(ag_last_content, 0, 150) } else { ag_last_content }
|
||||
let ag_seed: String = if !str_eq(ag_thread_snip, "") { ag_thread_snip + " " + message } else { message }
|
||||
|
||||
let ctx: String = engram_compile(ag_seed)
|
||||
let identity: String = state_get("soul_identity")
|
||||
let system: String = identity + " You have access to tools: read files, write files, browse the web, search your memory, run commands. Use them when they add genuine value. Be direct.\n\n" + ctx
|
||||
|
||||
@@ -420,15 +579,37 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
let tools_json: String = agentic_tools_with_web()
|
||||
let safe_msg: String = json_safe(message)
|
||||
let safe_sys: String = json_safe(system)
|
||||
let messages: String = "[{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]"
|
||||
|
||||
// Seed the messages array with recent history if available, so the LLM sees the thread.
|
||||
let prior_messages: String = if agentic_hist_len > 0 {
|
||||
let inner: String = str_slice(agentic_hist, 1, str_len(agentic_hist) - 1)
|
||||
"[" + inner + ",{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]"
|
||||
} else {
|
||||
"[{\"role\":\"user\",\"content\":\"" + safe_msg + "\"}]"
|
||||
}
|
||||
let messages: String = prior_messages
|
||||
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 session_id: String = next_bridge_id()
|
||||
return agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
|
||||
// Use caller-supplied session_id if provided, otherwise generate a bridge id.
|
||||
let session_id: String = if str_eq(req_session, "") { next_bridge_id() } else { req_session }
|
||||
let result: String = agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
|
||||
|
||||
// Persist the exchange to session/global history for thread continuity on next turn.
|
||||
// Only save when the loop completed (reply present), not when tool_pending.
|
||||
let reply_text: String = json_get(result, "reply")
|
||||
let discard_hist: Bool = if !str_eq(reply_text, "") {
|
||||
let updated: String = hist_append(agentic_hist, "user", message)
|
||||
let updated2: String = hist_append(updated, "assistant", reply_text)
|
||||
let trimmed: String = if json_array_len(updated2) > 20 { hist_trim(updated2) } else { updated2 }
|
||||
state_set(hist_key, trimmed)
|
||||
true
|
||||
} else { false }
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// agentic_loop — the resumable agentic turn. Runs the Anthropic tool-use loop and
|
||||
|
||||
Reference in New Issue
Block a user