Merge PR #5: feat(soul): MCP tool-bridge — suspend agentic loop for client-executed tools
This commit is contained in:
@@ -300,6 +300,30 @@ fn dispatch_tool(tool_name: String, tool_input: String) -> String {
|
||||
return "unknown tool: " + tool_name
|
||||
}
|
||||
|
||||
// is_builtin_tool — true when the soul can execute the tool itself in-process.
|
||||
// Anything else (MCP connectors / plugins surfaced by the Kotlin desktop app) must
|
||||
// be executed CLIENT-side via the tool-bridge: the agentic loop suspends and asks
|
||||
// the client to run it. The native web_search tool is executed by Anthropic, so it
|
||||
// never reaches dispatch_tool and is not listed here.
|
||||
fn is_builtin_tool(tool_name: String) -> Bool {
|
||||
return str_eq(tool_name, "read_file")
|
||||
|| str_eq(tool_name, "write_file")
|
||||
|| str_eq(tool_name, "web_get")
|
||||
|| str_eq(tool_name, "search_memory")
|
||||
|| str_eq(tool_name, "run_command")
|
||||
}
|
||||
|
||||
// next_bridge_id — monotonic correlation id for a suspended agentic turn.
|
||||
// Combines boot-relative time with a per-process counter so two unknown-tool
|
||||
// suspensions in the same second still get distinct ids.
|
||||
fn next_bridge_id() -> String {
|
||||
let prev: String = state_get("mcp_bridge_seq")
|
||||
let n: Int = if str_eq(prev, "") { 0 } else { str_to_int(prev) }
|
||||
let next: Int = n + 1
|
||||
state_set("mcp_bridge_seq", int_to_str(next))
|
||||
return "br-" + int_to_str(time_now()) + "-" + int_to_str(next)
|
||||
}
|
||||
|
||||
fn handle_chat_agentic(body: String) -> String {
|
||||
let message: String = json_get(body, "message")
|
||||
if str_eq(message, "") {
|
||||
@@ -324,11 +348,40 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
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, "")
|
||||
}
|
||||
|
||||
// agentic_loop — the resumable agentic turn. Runs the Anthropic tool-use loop and
|
||||
// returns one of two JSON envelopes:
|
||||
// - done: {"reply":...,"model":...,"agentic":true,"tools_used":[...]}
|
||||
// - pending: {"tool_pending":true,"session_id":...,"call_id":...,"tool_name":...,
|
||||
// "tool_input":{...},"tools_used":[...]} (HTTP 200)
|
||||
// The "pending" envelope is the CLIENT-BRIDGE signal: the loop has hit a tool the
|
||||
// soul cannot run in-process (an MCP connector/plugin the desktop app exposes). The
|
||||
// loop's full continuation (messages so far + the awaiting tool_use_id) is persisted
|
||||
// under state key "mcp_bridge:<session_id>". The client executes the MCP tool and
|
||||
// POSTs the result to /api/sessions/{session_id}/tool_result, which calls
|
||||
// agentic_resume to continue from exactly here. This mirrors Anthropic's own
|
||||
// tool_use round-trip, just with the soul as orchestrator and the client as executor.
|
||||
//
|
||||
// `tools_log_in` carries any tool names already used in a prior (pre-suspension) leg
|
||||
// so the final tools_used list survives a resume.
|
||||
fn agentic_loop(session_id: String, model: String, safe_sys: String, tools_json: String, messages_in: String, h: Map, tools_log_in: String) -> String {
|
||||
let api_url: String = "https://api.anthropic.com/v1/messages"
|
||||
|
||||
let messages: String = messages_in
|
||||
let final_text: String = ""
|
||||
let tools_log: String = ""
|
||||
let tools_log: String = tools_log_in
|
||||
let iteration: Int = 0
|
||||
let keep_going: Bool = true
|
||||
|
||||
// Suspension state — captured at top level so it escapes the while body.
|
||||
let pending: Bool = false
|
||||
let pend_tool_id: String = ""
|
||||
let pend_tool_name: String = ""
|
||||
let pend_tool_input: String = ""
|
||||
|
||||
while keep_going && iteration < 8 {
|
||||
let req_body: String = "{\"model\":\"" + model + "\""
|
||||
+ ",\"max_tokens\":4096"
|
||||
@@ -375,8 +428,13 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
let ci = ci + 1
|
||||
}
|
||||
|
||||
// Dispatch tool and build result message
|
||||
let tool_result_raw: String = if has_tool { dispatch_tool(tool_name, tool_input) } else { "" }
|
||||
// A real tool turn that targets a tool the soul cannot run in-process is a
|
||||
// CLIENT bridge: suspend the loop and hand the tool to the client.
|
||||
let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool
|
||||
let needs_bridge: Bool = is_tool_turn && !is_builtin_tool(tool_name)
|
||||
|
||||
// Built-in tools dispatch locally; bridged tools yield "" (never sent upstream).
|
||||
let tool_result_raw: String = if is_tool_turn && !needs_bridge { dispatch_tool(tool_name, tool_input) } else { "" }
|
||||
// Truncate large tool results (web pages etc) to avoid oversized requests
|
||||
let tool_result: String = if str_len(tool_result_raw) > 6000 {
|
||||
str_slice(tool_result_raw, 0, 6000) + "...[truncated]"
|
||||
@@ -390,20 +448,50 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
if str_eq(tools_log, "") { tool_quoted } else { tools_log + "," + tool_quoted }
|
||||
} else { tools_log }
|
||||
|
||||
// Update messages and loop state — all at top level using if-expressions
|
||||
let is_tool_turn: Bool = str_eq(stop_reason, "tool_use") && has_tool
|
||||
// The assistant turn that requested the tool — needed verbatim on resume so the
|
||||
// tool_use/tool_result pairing stays valid when the client posts its result.
|
||||
let inner: String = str_slice(messages, 1, str_len(messages) - 1)
|
||||
let messages = if is_tool_turn {
|
||||
"[" + inner
|
||||
let messages_with_assistant: String = "[" + inner
|
||||
+ ",{\"role\":\"assistant\",\"content\":" + eff_content + "}"
|
||||
+ ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}"
|
||||
+ "]"
|
||||
|
||||
// Local built-in tool turn: append assistant + tool_result and keep looping.
|
||||
let local_continue: Bool = is_tool_turn && !needs_bridge
|
||||
let messages = if local_continue {
|
||||
let inner2: String = str_slice(messages_with_assistant, 1, str_len(messages_with_assistant) - 1)
|
||||
"[" + inner2 + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
|
||||
} else { messages }
|
||||
|
||||
// Bridge turn: persist the continuation and stop the loop.
|
||||
let pending = if needs_bridge { true } else { pending }
|
||||
let pend_tool_id = if needs_bridge { tool_id } else { pend_tool_id }
|
||||
let pend_tool_name = if needs_bridge { tool_name } else { pend_tool_name }
|
||||
let pend_tool_input = if needs_bridge { tool_input } else { pend_tool_input }
|
||||
// Stash messages-with-the-assistant-request so resume only needs to append the
|
||||
// client's tool_result block. messages_with_assistant is only meaningful when a
|
||||
// tool was requested, so guard on needs_bridge before persisting.
|
||||
if needs_bridge {
|
||||
bridge_save(session_id, model, safe_sys, tools_json, messages_with_assistant, tools_log, pend_tool_id)
|
||||
}
|
||||
|
||||
let final_text = if !is_tool_turn { text_out } else { final_text }
|
||||
let keep_going = if !is_tool_turn { false } else { keep_going }
|
||||
let keep_going = if local_continue { keep_going } else { false }
|
||||
let iteration = iteration + 1
|
||||
}
|
||||
|
||||
if pending {
|
||||
let safe_in: String = if str_eq(pend_tool_input, "") { "{}" } else { pend_tool_input }
|
||||
let tools_arr: String = if str_eq(tools_log, "") { "[]" } else { "[" + tools_log + "]" }
|
||||
return "{\"tool_pending\":true"
|
||||
+ ",\"session_id\":\"" + session_id + "\""
|
||||
+ ",\"call_id\":\"" + pend_tool_id + "\""
|
||||
+ ",\"tool_name\":\"" + pend_tool_name + "\""
|
||||
+ ",\"tool_input\":" + safe_in
|
||||
+ ",\"model\":\"" + model + "\""
|
||||
+ ",\"agentic\":true"
|
||||
+ ",\"tools_used\":" + tools_arr + "}"
|
||||
}
|
||||
|
||||
if str_eq(final_text, "") {
|
||||
return "{\"error\":\"no response\",\"reply\":\"\"}"
|
||||
}
|
||||
@@ -413,6 +501,81 @@ fn handle_chat_agentic(body: String) -> String {
|
||||
return "{\"reply\":\"" + safe_text + "\",\"model\":\"" + model + "\",\"agentic\":true,\"tools_used\":" + tools_arr + "}"
|
||||
}
|
||||
|
||||
// bridge_save — persist a suspended agentic turn keyed by session_id. Stored as a
|
||||
// single JSON blob in soul state so agentic_resume can rebuild the exact loop. The
|
||||
// stored `messages` already includes the assistant turn that requested the tool, so
|
||||
// resume just appends the client's tool_result for `tool_use_id`.
|
||||
fn bridge_save(session_id: String, model: String, safe_sys: String, tools_json: String, messages: String, tools_log: String, tool_use_id: String) -> Bool {
|
||||
let blob: String = "{\"model\":\"" + json_safe(model) + "\""
|
||||
+ ",\"safe_sys\":\"" + json_safe(safe_sys) + "\""
|
||||
+ ",\"tools_json\":\"" + json_safe(tools_json) + "\""
|
||||
+ ",\"messages\":\"" + json_safe(messages) + "\""
|
||||
+ ",\"tools_log\":\"" + json_safe(tools_log) + "\""
|
||||
+ ",\"tool_use_id\":\"" + json_safe(tool_use_id) + "\"}"
|
||||
state_set("mcp_bridge:" + session_id, blob)
|
||||
return true
|
||||
}
|
||||
|
||||
// agentic_resume — continue a suspended agentic turn after the client executed a
|
||||
// bridged (MCP) tool. The client POSTs the tool result to
|
||||
// /api/sessions/{session_id}/tool_result; routes.el hands the parsed fields here.
|
||||
// We append the client's tool_result to the saved conversation and re-enter the loop
|
||||
// from the top (which may suspend again on the next MCP tool, fully chaining).
|
||||
fn agentic_resume(session_id: String, tool_use_id: String, content: String) -> String {
|
||||
let blob: String = state_get("mcp_bridge:" + session_id)
|
||||
if str_eq(blob, "") {
|
||||
return "{\"error\":\"unknown session_id\",\"reply\":\"\"}"
|
||||
}
|
||||
|
||||
let model: String = json_get(blob, "model")
|
||||
let safe_sys: String = json_get(blob, "safe_sys")
|
||||
let tools_json: String = json_get(blob, "tools_json")
|
||||
let messages: String = json_get(blob, "messages")
|
||||
let tools_log: String = json_get(blob, "tools_log")
|
||||
let saved_use_id: String = json_get(blob, "tool_use_id")
|
||||
|
||||
// Bind the result to the tool the soul actually suspended on. The client should
|
||||
// echo the call_id; if it omits or mismatches it, fall back to the saved id so a
|
||||
// late/partial client still resumes correctly.
|
||||
let use_id: String = if str_eq(tool_use_id, "") { saved_use_id } else { tool_use_id }
|
||||
let eff_use_id: String = if str_eq(use_id, saved_use_id) { use_id } else { saved_use_id }
|
||||
|
||||
// Result may be large (an MCP page/file); truncate like local tool results do.
|
||||
let trimmed: String = if str_len(content) > 6000 {
|
||||
str_slice(content, 0, 6000) + "...[truncated]"
|
||||
} else { content }
|
||||
let safe_result: String = json_safe(trimmed)
|
||||
let tool_msg: String = "{\"type\":\"tool_result\",\"tool_use_id\":\"" + eff_use_id + "\",\"content\":\"" + safe_result + "\"}"
|
||||
|
||||
let inner: String = str_slice(messages, 1, str_len(messages) - 1)
|
||||
let resumed_messages: String = "[" + inner + ",{\"role\":\"user\",\"content\":[" + tool_msg + "]}]"
|
||||
|
||||
// One-shot: clear the saved turn so a session_id can't be replayed.
|
||||
state_set("mcp_bridge:" + session_id, "")
|
||||
|
||||
let api_key: String = agentic_api_key()
|
||||
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")
|
||||
|
||||
return agentic_loop(session_id, model, safe_sys, tools_json, resumed_messages, h, tools_log)
|
||||
}
|
||||
|
||||
// handle_tool_result — entry point for POST /api/sessions/{id}/tool_result.
|
||||
// Body: {"call_id":"<tool_use_id from the pending envelope>","content":"<MCP tool
|
||||
// output as a string>"}. session_id comes from the URL path. Returns the SAME
|
||||
// envelope shape as /api/chat agentic: either a final {"reply":...} or another
|
||||
// {"tool_pending":...} if the continuation hits a further MCP tool.
|
||||
fn handle_tool_result(session_id: String, body: String) -> String {
|
||||
if str_eq(session_id, "") {
|
||||
return "{\"error\":\"session_id required\",\"reply\":\"\"}"
|
||||
}
|
||||
let call_id: String = json_get(body, "call_id")
|
||||
let content: String = json_get(body, "content")
|
||||
return agentic_resume(session_id, call_id, content)
|
||||
}
|
||||
|
||||
// handle_chat_as_soul — multi-soul room dispatch handler.
|
||||
//
|
||||
// The Studio is the orchestrator for DHARMA rooms; it has already assembled
|
||||
|
||||
@@ -305,6 +305,16 @@ fn handle_request(method: String, path: String, body: String) -> String {
|
||||
}
|
||||
|
||||
if str_eq(method, "POST") {
|
||||
// MCP tool-bridge resume: POST /api/sessions/{id}/tool_result
|
||||
// The client executed a tool the soul could not run in-process (an MCP
|
||||
// connector/plugin) and posts the result back here so the agentic loop
|
||||
// continues. {id} is the session_id from the prior tool_pending envelope.
|
||||
if str_starts_with(clean, "/api/sessions/") && str_ends_with(clean, "/tool_result") {
|
||||
let after: String = str_slice(clean, 14, str_len(clean))
|
||||
let slash: Int = str_index_of(after, "/")
|
||||
let session_id: String = if slash < 0 { after } else { str_slice(after, 0, slash) }
|
||||
return handle_tool_result(session_id, body)
|
||||
}
|
||||
if str_eq(clean, "/imprint/contextual") {
|
||||
return route_imprint_contextual(body)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user