Merge pull request 'fix(chat): wire agentic_tools_all into both agentic loop entry points' (#19) from fix/agentic-tools-all into main
fix(chat): wire agentic_tools_all into both agentic loop entry points
This commit was merged in pull request #19.
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -976,6 +976,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()
|
||||
@@ -988,93 +989,43 @@ 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
|
||||
let result_error: String = json_get(loop_result, "error")
|
||||
if !str_eq(result_error, "") {
|
||||
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)
|
||||
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 {
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user