Compare commits

..

1 Commits

Author SHA1 Message Date
Tim Lingo cec2aa7168 feat(connectors): /api/connectors/call — proxy a connector tool call (pre-chat)
Neuron Soul CI / build (pull_request) Failing after 21m3s
Adds /api/connectors/call -> connectd /mcp/call, so the app can invoke a connector tool (e.g. WhatsApp
get_pairing_qr / get_login_status for the pairing UI) through the soul, keeping app->soul->connectd
intact (UI never hits connectd directly) and working for future remote/hosted clients. elc-clean.
NOTE: soul-core change — needs dist/soul.c regen (Will), can ride the same rebuild as PR #56.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 14:42:57 -05:00
3 changed files with 9 additions and 115 deletions
+1 -88
View File
@@ -1202,86 +1202,6 @@ fn agentic_api_key() -> String {
return env("NEURON_LLM_0_KEY")
}
// OpenAI-compatible providers (Ollama / OpenAI / Grok / Gemini)
// The brain speaks Anthropic's Messages format by default. When the active provider uses the
// OpenAI-compatible wire format (NEURON_LLM_0_FORMAT=openai) with a configured base URL
// (NEURON_LLM_0_URL, e.g. http://localhost:11434/v1 for local Ollama), basic chat turns are served
// here instead of the Anthropic agentic loop.
// v1 SCOPE: plain chat completion only NO tools / agentic loop yet (that is a follow-up port).
// This block is ADDITIVE: the Anthropic path is untouched and stays the default.
fn llm_base_url() -> String {
return env("NEURON_LLM_0_URL")
}
fn llm_wire_format() -> String {
let f: String = env("NEURON_LLM_0_FORMAT")
if str_eq(f, "") {
return "anthropic"
}
return f
}
// Escape a decoded string so it can be embedded back into a JSON string literal.
fn json_escape(s: String) -> String {
let a: String = str_replace(s, "\\", "\\\\")
let b: String = str_replace(a, "\"", "\\\"")
let c: String = str_replace(b, "\n", "\\n")
let d: String = str_replace(c, "\r", "\\r")
return d
}
// Basic (non-agentic) chat completion against an OpenAI-compatible endpoint.
// [safe_sys] is already JSON-escaped; [messages_json] is the same JSON array the Anthropic path
// builds (e.g. [{"role":"user","content":"..."}]). Returns the soul's standard {"reply":"..."}.
fn openai_chat_complete(model: String, base_url: String, api_key: String, safe_sys: String, messages_json: String) -> String {
// Prepend the system prompt as an OpenAI "system" message, then the existing turn array.
let inner: String = if json_array_len(messages_json) > 0 {
str_slice(messages_json, 1, str_len(messages_json) - 1)
} else {
""
}
let msgs: String = if str_eq(inner, "") {
"[{\"role\":\"system\",\"content\":\"" + safe_sys + "\"}]"
} else {
"[{\"role\":\"system\",\"content\":\"" + safe_sys + "\"}," + inner + "]"
}
let req_body: String = "{\"model\":\"" + model + "\""
+ ",\"max_tokens\":4096"
+ ",\"messages\":" + msgs
+ "}"
let h: Map = {}
map_set(h, "content-type", "application/json")
// Ollama needs no key; OpenAI / Grok / Gemini use a Bearer token.
if !str_eq(api_key, "") {
map_set(h, "Authorization", "Bearer " + api_key)
}
let url: String = base_url + "/chat/completions"
let raw_resp: String = http_post_with_headers(url, req_body, h)
let is_error: Bool = str_starts_with(raw_resp, "{\"error\"") || str_contains(raw_resp, "\"error\":")
if is_error {
return "{\"error\":\"llm unavailable\",\"reply\":\"\"}"
}
// Parse OpenAI response shape: choices[0].message.content
let choices: String = json_get_raw(raw_resp, "choices")
let eff_choices: String = if str_eq(choices, "") {
"[]"
} else {
choices
}
if json_array_len(eff_choices) < 1 {
return "{\"error\":\"empty response\",\"reply\":\"\"}"
}
let first: String = json_array_get(eff_choices, 0)
let message: String = json_get_raw(first, "message")
let content: String = json_get(message, "content")
return "{\"reply\":\"" + json_escape(content) + "\",\"tools_used\":[]}"
}
fn agentic_tools_literal() -> String {
return "[" +
"{\"name\":\"read_file\",\"description\":\"Read contents of a file from disk.\",\"input_schema\":{\"type\":\"object\",\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Absolute file path\"}},\"required\":[\"path\"]}}," +
@@ -1784,14 +1704,7 @@ fn handle_chat_agentic(body: String) -> String {
// 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 }
// Provider fork: OpenAI-compatible providers (Ollama/OpenAI/Grok/Gemini) take the plain-completion
// path (v1, no tools); everything else stays on the Anthropic agentic loop (the default).
let use_openai: Bool = !str_eq(llm_base_url(), "") && str_eq(llm_wire_format(), "openai")
let result: String = if use_openai {
openai_chat_complete(model, llm_base_url(), agentic_api_key(), safe_sys, messages)
} else {
agentic_loop(session_id, model, safe_sys, tools_json, messages, h, "")
}
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.
+2 -27
View File
@@ -267,27 +267,6 @@ fn recall_or_list(query: String, limit: Int) -> String {
return http_post_json(neuron_url() + "/recall", body)
}
// Create a real typed node via /api/neuron/node/create (handle_api_node_create) so it is a proper
// BacklogItem/Artifact/etc. listable by type via /api/neuron/list/<type> instead of a generic
// memory blob. Maps title->label, content/description->content, project/priority->tags.
fn create_node_typed(args: String, node_type: String, tier: String) -> String {
let content: String = pick_content(args)
if str_eq(content, "") {
return mcp_text_result("error: content/title is required for " + node_type)
}
let title: String = json_get_string(args, "title")
let label: String = if str_eq(title, "") { node_type } else { title }
let project: String = json_get_string(args, "project")
let priority: String = json_get_string(args, "priority")
let proj_tag: String = if str_eq(project, "") { "" } else { ",\"project:" + project + "\"" }
let prio_tag: String = if str_eq(priority, "") { "" } else { ",\"priority:" + priority + "\"" }
let tags: String = "[\"" + node_type + "\"" + proj_tag + prio_tag + "]"
let body: String = "{\"node_type\":\"" + node_type + "\",\"content\":\"" + json_escape(content)
+ "\",\"label\":\"" + json_escape(label) + "\",\"tier\":\"" + tier + "\",\"tags\":" + tags + "}"
let resp: String = http_post_json(neuron_url() + "/node/create", body)
return mcp_json_result(resp)
}
fn search_with_query(args: String, default_limit: Int) -> String {
let query: String = json_get_string(args, "query")
if str_eq(query, "") { let query = pick_content(args) }
@@ -652,12 +631,8 @@ fn dispatch_tool_call(tool_name: String, args: String) -> String {
}
// Backlog + work
// planWork: create a REAL typed BacklogItem via /api/neuron/node/create (the old path fell through
// create_typed_node to a generic /memory write, dropping title/project/priority and never making a
// BacklogItem). reviewBacklog: LIST BacklogItem nodes (was a lexical /recall that never filtered by
// type). Both depend on the /api/neuron/list/<type> slice fix (neuron PR #58) to round-trip.
if str_eq(tool_name, "planWork") { return create_node_typed(args, "BacklogItem", "Working") }
if str_eq(tool_name, "reviewBacklog") { return list_typed("BacklogItem", 50, args) }
if str_eq(tool_name, "planWork") { return create_typed_node(args, "BacklogItem", "0.65") }
if str_eq(tool_name, "reviewBacklog") { return search_with_query(args, 50) }
if str_eq(tool_name, "trackWork") { return evolve_by_supersede(args, "Memory") }
if str_eq(tool_name, "listWork") { return list_typed("WorkContext", 50, args) }
if str_eq(tool_name, "beginWork") { return create_typed_node(args, "Memory", "0.70") }
+6
View File
@@ -335,6 +335,12 @@ fn handle_connectors(method: String, clean: String, body: String) -> String {
if str_eq(clean, "/api/connectors/oauth/start") {
return connectd_post("/mcp/oauth/start", body)
}
// Call a connector tool directly (pre-chat), e.g. WhatsApp get_pairing_qr / get_login_status for
// the pairing UI. Body: {"name":"mcp__<server>__<tool>","input":{...}}. Keeps the app on the
// app->soul->connectd path (the UI never hits connectd directly) and works for remote/hosted apps.
if str_eq(clean, "/api/connectors/call") {
return connectd_post("/mcp/call", body)
}
return "{\"ok\":false,\"error\":\"unknown connectors route\"}"
}