From 773004f23b6e02c6aa8bf948aea0f4c634ae301f Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Mon, 15 Jun 2026 13:06:28 -0500 Subject: [PATCH 1/2] fix(chat): wire agentic_tools_all into agentic loop paths handle_chat_agentic was calling agentic_tools_with_web(), which omits MCP connector tools, so mcp__* calls were never available in agentic mode even when neuron-connectd is running. Switch both agentic entry points to agentic_tools_all(). For handle_dharma_room_turn_agentic, also replace the inline 8-iteration loop with a call to agentic_loop() so bridge suspension and the full connector tool set work consistently. Session IDs are prefixed with 'dharma:' + room_id so suspensions stay room-scoped. --- chat.el | 89 +++++++++------------------------------------------------ 1 file changed, 13 insertions(+), 76 deletions(-) diff --git a/chat.el b/chat.el index 3e7f903..c2e4a61 100644 --- a/chat.el +++ b/chat.el @@ -576,7 +576,7 @@ fn handle_chat_agentic(body: String) -> String { 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 let api_key: String = agentic_api_key() - let tools_json: String = agentic_tools_with_web() + let tools_json: String = agentic_tools_all() let safe_msg: String = json_safe(message) let safe_sys: String = json_safe(system) @@ -962,6 +962,7 @@ fn handle_dharma_room_turn(body: String) -> String { fn handle_dharma_room_turn_agentic(body: String) -> String { let transcript: String = json_get(body, "transcript") + let room_id: String = json_get(body, "room_id") let identity: String = state_get("soul_identity") let cgi_id: String = state_get("soul_cgi_id") let model: String = chat_default_model() @@ -974,93 +975,29 @@ fn handle_dharma_room_turn_agentic(body: String) -> String { 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 and stay in character.\n\n" + ctx let api_key: String = agentic_api_key() - let tools_json: String = agentic_tools_literal() + let tools_json: String = agentic_tools_all() let safe_transcript: String = json_safe(transcript) let safe_sys: String = json_safe(system) let messages: String = "[{\"role\":\"user\",\"content\":\"" + safe_transcript + "\"}]" - 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 + // Use dharma-prefixed session_id so bridge suspension works correctly per room. + let session_id: String = if str_eq(room_id, "") { "dharma:" + next_bridge_id() } else { "dharma:" + room_id } + let loop_result: String = agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "") - while keep_going && iteration < 8 { - let req_body: String = "{\"model\":\"" + model + "\"" - + ",\"max_tokens\":4096" - + ",\"system\":\"" + safe_sys + "\"" - + ",\"tools\":" + tools_json - + ",\"messages\":" + 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\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" - } - - 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 tool_id: String = "" - let tool_name: String = "" - let 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 tool_id = if is_new_tool { json_get(block, "id") } else { tool_id } - let tool_name = if is_new_tool { json_get(block, "name") } else { tool_name } - let tool_input = if is_new_tool { json_get_raw(block, "input") } else { tool_input } - let ci = ci + 1 - } - - let tool_result_raw: String = if has_tool { dispatch_tool(tool_name, tool_input) } else { "" } - let tool_result: String = if str_len(tool_result_raw) > 6000 { - str_slice(tool_result_raw, 0, 6000) + "...[truncated]" - } else { tool_result_raw } - - let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + tool_id + "\",\"content\":\"" + tool_result + "\"}" - - let tool_quoted: String = "\"" + tool_name + "\"" - let tools_log = if has_tool { - if str_eq(tools_log, "") { tool_quoted } else { tools_log + "," + tool_quoted } - } else { tools_log } - - let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool - let inner: String = str_slice(messages, 1, str_len(messages) - 1) - let messages = if is_tool_turn { - "[" + inner - + ",{\"role\":\"assistant\",\"content\":" + eff_content + "}" - + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}" - + "]" - } else { messages } - let final_text = if !is_tool_turn { text_out } else { final_text } - let keep_going = if !is_tool_turn { false } else { keep_going } - let iteration = iteration + 1 - } - - if str_eq(final_text, "") { - return "{\"error\":\"no response\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" + let result_error: String = json_get(loop_result, "error") + if !str_eq(result_error, "") { + return "{\"error\":\"" + result_error + "\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" } + let final_text: String = json_get(loop_result, "reply") + let tools_arr: String = json_get_raw(loop_result, "tools_used") + let eff_tools: String = if str_eq(tools_arr, "") { "[]" } else { tools_arr } let safe_text: String = json_safe(final_text) - let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" } - return "{\"response\":\"" + safe_text + "\",\"cgi_id\":\"" + cgi_id + "\",\"tools_used\":" + tools_arr + "}" + return "{\"response\":\"" + safe_text + "\",\"cgi_id\":\"" + cgi_id + "\",\"tools_used\":" + eff_tools + "}" } fn auto_persist(req: String, resp: String) -> Void { From f7ae7df9d642f8ac9a8a8c169cc3681043e1efcf Mon Sep 17 00:00:00 2001 From: Will Anderson Date: Wed, 17 Jun 2026 13:01:13 -0500 Subject: [PATCH 2/2] fix/test(chat): guard handle_dharma_room_turn_agentic against tool_pending and empty reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When agentic_loop suspends for an MCP bridge tool it returns a {"tool_pending":true,...} envelope with no "reply" key. Without an explicit check, json_get(loop_result, "reply") returns "" and the function emitted {"response":"","cgi_id":"..."} — a silent empty response indistinguishable from a successful LLM turn with no content. Two guards added after the existing error check: 1. tool_pending passthrough: if the loop suspended, return the pending envelope directly so callers (dharma room orchestrators) can distinguish suspension from failure and route to the approve flow. 2. Empty-reply guard: if final_text is empty after the pending check, return an explicit {"error":"no response",...} envelope instead of silently succeeding with an empty response field. Also adds tests/test_agentic_tools.el: - agentic_tools_all() includes all literal tool names and web_search - connector_tools_json() returns valid JSON when bridge is down (graceful degradation) - tool_pending envelope detection patterns (the is_pending logic) - json_get(pending_envelope, "reply") returns "" confirming the empty-reply guard is load-bearing (pure string/JSON, no LLM or network required) --- chat.el | 14 +++ tests/test_agentic_tools.el | 176 ++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 tests/test_agentic_tools.el diff --git a/chat.el b/chat.el index c2e4a61..0509ccb 100644 --- a/chat.el +++ b/chat.el @@ -993,7 +993,21 @@ fn handle_dharma_room_turn_agentic(body: String) -> String { return "{\"error\":\"" + result_error + "\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" } + // If agentic_loop suspended for an MCP bridge tool, pass the pending envelope + // straight through so callers can distinguish suspension from failure. + // A silent empty response is indistinguishable from an LLM error to any caller. + let is_pending: Bool = str_eq(json_get(loop_result, "tool_pending"), "true") + || str_starts_with(loop_result, "{\"tool_pending\":true") + if is_pending { + return loop_result + } + let final_text: String = json_get(loop_result, "reply") + // Guard against a silent empty response - produce an explicit error so callers + // cannot mistake a failed turn for a successful one with empty content. + if str_eq(final_text, "") { + return "{\"error\":\"no response\",\"response\":\"\",\"cgi_id\":\"" + cgi_id + "\"}" + } let tools_arr: String = json_get_raw(loop_result, "tools_used") let eff_tools: String = if str_eq(tools_arr, "") { "[]" } else { tools_arr } let safe_text: String = json_safe(final_text) diff --git a/tests/test_agentic_tools.el b/tests/test_agentic_tools.el new file mode 100644 index 0000000..1544585 --- /dev/null +++ b/tests/test_agentic_tools.el @@ -0,0 +1,176 @@ +// tests/test_agentic_tools.el +// Tests for the agentic tools wiring (PR #19: fix/agentic-tools-all). +// +// Covers: +// 1. agentic_tools_all() includes all literal tool names +// 2. agentic_tools_all() includes the native web_search tool +// 3. connector_tools_json() returns valid JSON ([] or array) even when bridge is down +// 4. agentic_tools_all() output stays valid JSON when connector bridge is down +// 5. tool_pending envelope detection — the pattern used in handle_dharma_room_turn_agentic +// to distinguish a suspended agentic loop from a normal reply +// 6. Empty-reply guard — json_get("reply") returns "" on a tool_pending envelope, +// confirming that the guard is necessary to avoid silent empty responses +// +// Tests 5 and 6 validate the El-level logic that guards handle_dharma_room_turn_agentic +// against silent failures after the refactor to use agentic_loop. +// +// Tests 1-4 are pure: no network, no LLM, no engram. +// Tests 5-6 are pure string/JSON operations on synthesized envelopes. +// +// Integration tests (LLM-live) are documented as SKIP stubs because they +// require a valid ANTHROPIC_API_KEY and a running soul + neuron-connectd. + +import "../chat.el" + +let pass_count: Int = 0 +let fail_count: Int = 0 + +fn assert_eq(label: String, got: String, expected: String) -> Void { + if str_eq(got, expected) { + let pass_count = pass_count + 1 + println(" PASS: " + label) + } else { + let fail_count = fail_count + 1 + println(" FAIL: " + label) + println(" got: " + got) + println(" expected: " + expected) + } +} + +fn assert_true(label: String, cond: Bool) -> Void { + if cond { + let pass_count = pass_count + 1 + println(" PASS: " + label) + } else { + let fail_count = fail_count + 1 + println(" FAIL: " + label) + } +} + +fn assert_contains(label: String, haystack: String, needle: String) -> Void { + if str_contains(haystack, needle) { + let pass_count = pass_count + 1 + println(" PASS: " + label) + } else { + let fail_count = fail_count + 1 + println(" FAIL: " + label) + println(" missing '" + needle + "' in: " + haystack) + } +} + +fn assert_not_empty(label: String, s: String) -> Void { + if str_len(s) > 0 { + let pass_count = pass_count + 1 + println(" PASS: " + label) + } else { + let fail_count = fail_count + 1 + println(" FAIL: " + label) + println(" got empty string") + } +} + +// ── Section 1: agentic_tools_all contains all literal tool names ────────────── + +println("") +println("1. agentic_tools_all() — contains all literal tool names") + +let all_tools: String = agentic_tools_all() +assert_contains("contains read_file", all_tools, "\"name\":\"read_file\"") +assert_contains("contains write_file", all_tools, "\"name\":\"write_file\"") +assert_contains("contains web_get", all_tools, "\"name\":\"web_get\"") +assert_contains("contains search_memory", all_tools, "\"name\":\"search_memory\"") +assert_contains("contains run_command", all_tools, "\"name\":\"run_command\"") + +// ── Section 2: agentic_tools_all includes native web_search ────────────────── + +println("") +println("2. agentic_tools_all() — includes native web_search_20250305 tool") + +assert_contains("contains web_search type", all_tools, "web_search_20250305") +assert_contains("contains web_search name", all_tools, "\"name\":\"web_search\"") + +// ── Section 3: connector_tools_json returns valid JSON when bridge is down ──── + +println("") +println("3. connector_tools_json() — returns [] when neuron-connectd is not running") + +// connector_tools_json() calls the bridge; in a unit-test environment it is +// expected to return "[]" (graceful degradation). If the bridge IS running, +// it returns a non-empty array — both are valid. +let conn_tools: String = connector_tools_json() +let starts_bracket: Bool = str_starts_with(conn_tools, "[") +assert_true("connector_tools_json starts with [", starts_bracket) +assert_not_empty("connector_tools_json is non-empty string", conn_tools) + +// ── Section 4: agentic_tools_all output is valid JSON array ────────────────── + +println("") +println("4. agentic_tools_all() — output is a JSON array") + +assert_true("starts with [", str_starts_with(all_tools, "[")) +// A JSON array ends with ] +let last_char: String = str_slice(all_tools, str_len(all_tools) - 1, str_len(all_tools)) +assert_eq("ends with ]", last_char, "]") + +// ── Section 5: tool_pending envelope detection ──────────────────────────────── +// +// This validates the detection logic added to handle_dharma_room_turn_agentic: +// +// let is_pending: Bool = str_eq(json_get(loop_result, "tool_pending"), "true") +// || str_starts_with(loop_result, "{\"tool_pending\":true") +// +// When agentic_loop suspends for an MCP bridge tool it returns: +// {"tool_pending":true,"session_id":"...","call_id":"...","tool_name":"...","tool_input":{...},...} +// +// json_get() on a Bool field may return "true" (string) or "" depending on El runtime. +// The str_starts_with fallback guards against either representation. + +println("") +println("5. tool_pending envelope detection patterns") + +let pending_envelope: String = "{\"tool_pending\":true,\"session_id\":\"dharma:br-1234-1\",\"call_id\":\"toolu_01\",\"tool_name\":\"mcp__filesystem__read\",\"tool_input\":{\"path\":\"/tmp/x\"},\"model\":\"claude-sonnet-4-5\",\"agentic\":true,\"tools_used\":[]}" +let normal_envelope: String = "{\"reply\":\"Hello from the soul.\",\"model\":\"claude-sonnet-4-5\",\"agentic\":true,\"tools_used\":[]}" +let error_envelope: String = "{\"error\":\"llm unavailable\",\"reply\":\"\"}" + +// str_starts_with fallback — always works regardless of how json_get handles bool +assert_true("pending envelope: str_starts_with detects tool_pending=true", str_starts_with(pending_envelope, "{\"tool_pending\":true")) +assert_true("normal reply: str_starts_with does not detect tool_pending", !str_starts_with(normal_envelope, "{\"tool_pending\":true")) +assert_true("error envelope: str_starts_with does not detect tool_pending", !str_starts_with(error_envelope, "{\"tool_pending\":true")) + +// ── Section 6: empty-reply guard necessity ──────────────────────────────────── +// +// Confirms that json_get(pending_envelope, "reply") returns "" — proving the +// empty-reply guard is necessary to avoid a silent success with empty response. +// Without the guard, the old code would return {"response":"","cgi_id":"..."} which +// is indistinguishable from a successful LLM response. + +println("") +println("6. empty-reply guard — json_get(pending, \"reply\") is empty") + +let pending_reply: String = json_get(pending_envelope, "reply") +assert_eq("json_get reply on pending envelope is empty", pending_reply, "") + +let normal_reply: String = json_get(normal_envelope, "reply") +assert_not_empty("json_get reply on normal envelope is non-empty", normal_reply) + +// Also confirm error key absent from normal reply and pending envelopes +let pending_error: String = json_get(pending_envelope, "error") +assert_eq("pending envelope has no error key", pending_error, "") + +let normal_error: String = json_get(normal_envelope, "error") +assert_eq("normal envelope has no error key", normal_error, "") + +// ── SKIP stubs: integration tests requiring live LLM ───────────────────────── + +println("") +println("SKIP: handle_dharma_room_turn_agentic happy-path (requires ANTHROPIC_API_KEY + soul)") +println(" Expected: non-empty response field and status ok") +println("SKIP: handle_dharma_room_turn_agentic tool_pending propagation (requires API + MCP bridge)") +println(" Expected: tool_pending in response when loop suspends for mcp__* tool") +println("SKIP: handle_chat_agentic connector tools end-to-end (requires API + neuron-connectd)") +println(" Expected: mcp__* tool names appear in tools_used when connectd is running") + +// ── Summary ─────────────────────────────────────────────────────────────────── + +println("") +println("agentic tools tests: " + int_to_str(pass_count) + " passed, " + int_to_str(fail_count) + " failed")