Entropic 2.11.1
Local-first agentic inference engine
Loading...
Searching...
No Matches
external_bridge.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
13#include <entropic/entropic.h>
15
16#include "engine_handle.h"
17#include "final_text.h" // gh#130 (v2.10.2)
18
19#include <nlohmann/json.hpp>
20
21#include <atomic>
22#include <cerrno>
23#include <chrono>
24#include <cstring>
25#include <filesystem>
26#include <fstream>
27#include <functional>
28#include <mutex>
29#include <sstream>
30#include <thread>
31#include <unordered_map>
32#include <unordered_set>
33#include <unistd.h>
34#include <sys/socket.h>
35#include <sys/stat.h>
36#include <sys/un.h>
37#include <poll.h>
38
39using json = nlohmann::json;
40
41namespace entropic {
42
43static auto logger = entropic::log::get("mcp.external_bridge");
44
45// gh#58: process-wide set of socket paths currently bound by an
46// ExternalBridge in this process. Without this, a second handle whose
47// project_dir hashes to the same socket path as a first handle would
48// unlink the live socket out from under handle #1 (see the
49// std::filesystem::remove call in create_listen_socket). With this
50// guard, the second handle declines to start its bridge instead.
51static std::mutex s_bound_sockets_mu;
52static std::unordered_set<std::string> s_bound_sockets;
53
54// Forward declaration
55std::filesystem::path compute_socket_path(
56 const std::filesystem::path& project_dir);
57
58// ── JSON-RPC helpers ─────────────────────────────────────
59
68static std::string rpc_ok(const json& id, const json& result) {
69 json r = {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}};
70 return r.dump();
71}
72
82static std::string rpc_err(const json& id, int code,
83 const std::string& msg) {
84 json r = {{"jsonrpc", "2.0"}, {"id", id},
85 {"error", {{"code", code}, {"message", msg}}}};
86 return r.dump();
87}
88
96static json tool_text(const std::string& text) {
97 return {{"content", json::array({{{"type", "text"}, {"text", text}}})}};
98}
99
100// ── Tool definitions ─────────────────────────────────────
101
108static json tool_definitions() {
109 return json::array({
110 {{"name", "entropic.ask"},
111 {"description",
112 "Submit a prompt to the running entropic engine. "
113 "Set async=true to return immediately with a task_id; "
114 "the engine pushes a notification when done."},
115 {"inputSchema", {
116 {"type", "object"},
117 {"properties", {
118 {"prompt", {{"type", "string"},
119 {"description", "User message"}}},
120 {"async", {{"type", "boolean"},
121 {"description", "Run asynchronously"},
122 {"default", false}}}
123 }},
124 {"required", json::array({"prompt"})}
125 }}},
126 {{"name", "entropic.ask_status"},
127 {"description",
128 "Check status of an async entropic.ask task."},
129 {"inputSchema", {
130 {"type", "object"},
131 {"properties", {{"task_id", {
132 {"type", "string"},
133 {"description", "Task ID from async ask"}
134 }}}},
135 {"required", json::array({"task_id"})}
136 }}},
137 {{"name", "entropic.status"},
138 {"description", "Engine version and message count."},
139 {"inputSchema", {{"type", "object"},
140 {"properties", json::object()}}}},
141 {{"name", "entropic.context_clear"},
142 {"description", "Clear conversation history."},
143 {"inputSchema", {{"type", "object"},
144 {"properties", json::object()}}}},
145 {{"name", "entropic.context_count"},
146 {"description", "Return the message count."},
147 {"inputSchema", {{"type", "object"},
148 {"properties", json::object()}}}},
149 });
150}
151
152// ── Tool handlers ────────────────────────────────────────
153
154// gh#130 (v2.10.2): extract_final_text moved to the private facade header
155// final_text.h so its selection rule is unit-testable — this was a `static`
156// helper with no reachable test, and it shipped a scanning bug that returned
157// "(no response)" to operators while the answer sat earlier in the same
158// conversation. See facade_text::extract_final_text for the rule.
159
167static void write_json_line(int fd, const json& msg) {
168 auto s = msg.dump() + "\n";
169 ::write(fd, s.c_str(), s.size());
170}
171
180static void send_progress(int fd, const std::string& token_text,
181 const std::string& progress_token) {
182 json notif = {
183 {"jsonrpc", "2.0"},
184 {"method", "notifications/progress"},
185 {"params", {
186 {"progressToken", progress_token},
187 {"progress", token_text}
188 }}
189 };
190 write_json_line(fd, notif);
191}
192
206 char* msgs_json = nullptr;
207 entropic_context_get(handle, &msgs_json);
208 auto answer = facade_text::final_text_or_reason(msgs_json);
209 entropic_free(msgs_json);
210 return tool_text(answer);
211}
212
228static json handle_ask_plain(entropic_handle_t handle, const json& args) {
229 auto it = args.find("prompt");
230 if (it == args.end() || !it->is_string()) {
231 return tool_text("error: missing 'prompt' argument");
232 }
233 char* result_json = nullptr;
234 auto err = entropic_run(handle, it->get<std::string>().c_str(),
235 &result_json);
236 if (err != ENTROPIC_OK) {
237 const char* msg = entropic_last_error(handle);
238 entropic_free(result_json);
239 return tool_text(std::string("error: ") + (msg ? msg : "unknown"));
240 }
241 // Resolve before the free — final_text_or_reason reads that buffer.
242 auto answer = facade_text::final_text_or_reason(result_json);
243 entropic_free(result_json);
244 return tool_text(answer);
245}
246
264static json handle_ask(entropic_handle_t handle, const json& args,
265 int client_fd, const std::string& call_id) {
266 auto it = args.find("prompt");
267 if (it == args.end() || !it->is_string()) {
268 return tool_text("error: missing 'prompt' argument");
269 }
270 std::string prompt = it->get<std::string>();
271
272 // Stream tokens as progress notifications
273 struct StreamCtx { int fd; std::string token_id; };
274 StreamCtx sctx{client_fd, call_id};
275 auto on_token = [](const char* tok, size_t len, void* ud) {
276 auto* ctx = static_cast<StreamCtx*>(ud);
277 send_progress(ctx->fd, std::string(tok, len), ctx->token_id);
278 };
279 auto err = entropic_run_streaming(
280 handle, prompt.c_str(), on_token, &sctx, nullptr);
281 if (err != ENTROPIC_OK) {
282 const char* msg = entropic_last_error(handle);
283 return tool_text(std::string("error: ") + (msg ? msg : "unknown"));
284 }
285
286 return final_answer_from_context(handle);
287}
288
296static json handle_status(entropic_handle_t handle) {
297 size_t count = 0;
299 std::ostringstream os;
300 os << "entropic " << entropic_version()
301 << "\nmessages: " << count;
302 // Metrics + per-tier breakdown (P2-15 follow-up, 2.0.6-rc16.2)
303 char* mjson = nullptr;
304 if (entropic_metrics_json(handle, &mjson) == ENTROPIC_OK
305 && mjson != nullptr) {
306 os << "\nmetrics: " << mjson;
307 entropic_free(mjson);
308 }
309 return tool_text(os.str());
310}
311
342 std::string status;
343 std::string phase;
344 std::string text;
345};
346
362 entropic_handle_t handle,
364 char* result_json) {
366 if (err == ENTROPIC_ERROR_CANCELLED
367 || err == ENTROPIC_ERROR_INTERRUPTED) {
368 const char* msg = entropic_last_error(handle);
369 s.text = msg ? msg : "cancelled";
370 s.status = "cancelled";
371 s.phase = "cancelled";
372 } else if (err != ENTROPIC_OK) {
373 const char* msg = entropic_last_error(handle);
374 s.text = msg ? msg : "unknown error";
375 s.status = "error";
376 s.phase = "failed";
377 } else {
378 // gh#130 (v2.10.2): same selection rule as the sync paths. This one
379 // had no fallback at all, so a stalled async ask reported
380 // status="done" with empty text — even less diagnosable than the
381 // sync path's "(no response)".
382 s.text = facade_text::final_text_or_reason(result_json);
383 entropic_free(result_json);
384 s.status = "done";
385 s.phase = "done";
386 }
387 return s;
388}
389
398 std::lock_guard<std::mutex> lock(bridge->tasks_mutex_);
399 bool any = false;
400 for (auto& [_, task] : bridge->tasks_for_cancel()) {
401 if (task.status == "queued" || task.status == "running") {
402 task.status = "cancelled";
403 task.phase = "cancelling";
404 any = true;
405 }
406 }
407 return any;
408}
409
417 std::lock_guard<std::mutex> lock(bridge->tasks_mutex_);
418 for (auto& [_, task] : bridge->tasks_for_cancel()) {
419 if (task.phase == "cancelling") { return true; }
420 }
421 return false;
422}
423
440 entropic_handle_t handle, ExternalBridge* bridge) {
441 if (bridge == nullptr) { return; }
442 entropic_interrupt(handle); // idempotent, cheap
443 if (!mark_tasks_cancelling(bridge)) { return; }
444 for (int i = 0; i < 20 && any_cancelling_left(bridge); ++i) {
445 std::this_thread::sleep_for(std::chrono::milliseconds(50));
446 }
447 bridge->detach_phase_observer(); // bump gen; silences any stale observer
448}
449
463 ExternalBridge* bridge) {
464 cancel_inflight_async_tasks(handle, bridge);
465 auto err = entropic_context_clear(handle);
466 if (err != ENTROPIC_OK) {
467 return tool_text("error: clear failed");
468 }
469 return tool_text("conversation cleared");
470}
471
479static json handle_count(entropic_handle_t handle) {
480 size_t count = 0;
482 return tool_text(std::to_string(count));
483}
484
485// ── Async ask status ─────────────────────────────────────
486
498json ExternalBridge::handle_ask_status(const json& args) {
499 auto tid = args.value("task_id", std::string{});
500 std::lock_guard<std::mutex> lock(tasks_mutex_);
501 auto it = tasks_.find(tid);
502 if (it == tasks_.end()) {
503 return tool_text("error: unknown task_id");
504 }
505 json status = {{"status", it->second.status},
506 {"phase", it->second.phase}};
507 if (!it->second.result.empty()) {
508 auto key = (it->second.status == "error") ? "error" : "result";
509 status[key] = it->second.result;
510 }
511 return tool_text(status.dump());
512}
513
514// ── UUID generation ──────────────────────────────────────
515
522static std::string generate_task_id() {
523 static std::atomic<uint64_t> counter{0};
524 auto n = counter.fetch_add(1);
525 auto t = std::chrono::steady_clock::now().time_since_epoch().count();
526 std::ostringstream ss;
527 ss << std::hex << (t ^ (n * 2654435761ULL));
528 return "task-" + ss.str();
529}
530
531// ── Dispatch ─────────────────────────────────────────────
532
545 ExternalBridge* bridge,
546 const json& args,
547 int client_fd,
548 const std::string& call_id) {
549 if (args.value("async", false)) {
550 auto task_id = generate_task_id();
551 bridge->run_async_ask(
552 args.value("prompt", ""), task_id, client_fd);
553 return tool_text("async task started: " + task_id);
554 }
555 if (!bridge->ask_streaming()) { return handle_ask_plain(handle, args); }
556 return handle_ask(handle, args, client_fd, call_id);
557}
558
576 ExternalBridge* bridge,
577 const json& params,
578 int client_fd,
579 const std::string& call_id) {
580 std::string name = params.value("name", std::string{});
581 json args = params.value("arguments", json::object());
582 if (name == "entropic.ask") {
583 return dispatch_ask(handle, bridge, args, client_fd, call_id);
584 }
585 json result;
586 if (name == "entropic.ask_status") { result = bridge->handle_ask_status(args); }
587 else if (name == "entropic.status") { result = handle_status(handle); }
588 else if (name == "entropic.context_clear") { result = handle_clear(handle, bridge); }
589 else if (name == "entropic.context_count") { result = handle_count(handle); }
590 else { result = tool_text("error: unknown tool '" + name + "'"); }
591 return result;
592}
593
594// ── ExternalBridge ───────────────────────────────────────
595
605 entropic_handle_t handle,
606 const ExternalMCPConfig& config,
607 const std::filesystem::path& project_dir)
608 : handle_(handle), config_(config) {
609 socket_path_ = config.socket_path.has_value()
610 ? config.socket_path.value()
611 : compute_socket_path(project_dir);
612}
613
622
634static void prepare_socket_dir(const std::filesystem::path& parent) {
635 std::error_code ec;
636 std::filesystem::create_directories(parent, ec);
637 ::chmod(parent.c_str(), S_IRWXU); // 0700
638}
639
653static bool socket_path_safe(const std::filesystem::path& path) {
654 struct stat st{};
655 if (::lstat(path.c_str(), &st) != 0) {
656 return errno == ENOENT; // absent is fine
657 }
658 bool is_symlink = S_ISLNK(st.st_mode);
659 bool is_socket = S_ISSOCK(st.st_mode);
660 if (!is_socket || is_symlink) {
661 logger->error(
662 "Refusing to bind: {} is a {} (expected unix socket)",
663 path.string(),
664 is_symlink ? "symlink" : "non-socket file");
665 }
666 return is_socket && !is_symlink;
667}
668
677static bool bind_and_listen(int fd, const std::filesystem::path& path) {
678 auto s = path.string();
679 if (s.size() >= sizeof(sockaddr_un::sun_path)) { return false; }
680 struct sockaddr_un addr{};
681 addr.sun_family = AF_UNIX;
682 std::strncpy(addr.sun_path, s.c_str(),
683 sizeof(addr.sun_path) - 1);
684 if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
685 return false;
686 }
687 ::chmod(s.c_str(), S_IRUSR | S_IWUSR); // 0600 — owner only
688 return listen(fd, 1) == 0;
689}
690
706static int create_listen_socket(const std::filesystem::path& path) {
707 prepare_socket_dir(path.parent_path());
708 if (!socket_path_safe(path)) { return -1; }
709 std::filesystem::remove(path);
710
711 int fd = socket(AF_UNIX, SOCK_STREAM, 0);
712 bool ok = (fd >= 0) && bind_and_listen(fd, path);
713 if (!ok) {
714 logger->error("Socket setup failed for {}: {}",
715 path.string(), std::strerror(errno));
716 if (fd >= 0) { ::close(fd); }
717 return -1;
718 }
719 logger->info(
720 "External MCP bridge ready: project_dir(canonical)={} socket={}",
721 path.parent_path().parent_path().string(), path.string());
722 return fd;
723}
724
739static bool peer_uid_matches(int client_fd) {
740 struct ucred cred{};
741 socklen_t len = sizeof(cred);
742 if (getsockopt(client_fd, SOL_SOCKET, SO_PEERCRED,
743 &cred, &len) != 0) {
744 logger->warn("SO_PEERCRED failed on fd={}: {}",
745 client_fd, std::strerror(errno));
746 return false;
747 }
748 if (cred.uid != ::geteuid()) {
749 logger->warn(
750 "Rejecting MCP client on fd={}: peer uid {} != engine uid {}",
751 client_fd, cred.uid, ::geteuid());
752 return false;
753 }
754 return true;
755}
756
764 // Refuse to start if another handle in this process already owns
765 // this socket path. The pre-fix create_listen_socket call unlinks
766 // any existing socket file before binding — that's correct for
767 // crashed-prior-process recovery, but if a live in-process bridge
768 // owned that file, the unlink stole its binding silently. The
769 // claim must happen *before* create_listen_socket so a concurrent
770 // start() on a colliding path cannot pass the check and then race
771 // into the unlink.
772 std::error_code ec;
773 auto canonical =
774 std::filesystem::weakly_canonical(socket_path_, ec).string();
775 if (ec) { canonical = socket_path_.string(); }
776 {
777 std::lock_guard lk(s_bound_sockets_mu);
778 if (!s_bound_sockets.insert(canonical).second) {
779 logger->warn(
780 "External MCP bridge: socket {} already bound by another "
781 "handle in this process; declining to start. Set "
782 "external.socket_path to a distinct path per handle "
783 "if you need per-handle bridges.",
784 canonical);
785 return false;
786 }
787 }
788
789 listen_fd_ = create_listen_socket(socket_path_);
790 if (listen_fd_ < 0) {
791 std::lock_guard lk(s_bound_sockets_mu);
792 s_bound_sockets.erase(canonical);
793 return false;
794 }
795 bound_canonical_ = canonical;
796
797 running_.store(true);
798 // gh#59 (v2.3.1): the accept thread (and the per-client serve
799 // threads it spawns) need to log on this handle's behalf — install
800 // a HandleLogScope at the top of each thread body so spdlog lines
801 // route through HandleAwareSink to this handle's session.log.
802 int log_id = handle_ ? handle_->log_id : 0;
803 accept_thread_ = std::thread([this, log_id]() {
804 entropic::log::HandleLogScope scope(log_id);
805 accept_loop();
806 });
807
808 logger->info("External MCP bridge listening on {}",
809 socket_path_.string());
810 return true;
811}
812
834 running_.store(false);
835 if (listen_fd_ >= 0) {
836 ::close(listen_fd_);
837 listen_fd_ = -1;
838 }
839 if (accept_thread_.joinable()) {
840 accept_thread_.join();
841 }
842
843 // Wake every connected client's blocking read so the per-client
844 // thread can observe the disconnect and exit. shutdown(SHUT_RDWR)
845 // on a connected socket returns EOF to the peer; the read in
846 // serve_client returns 0 and read_line returns "" → exits.
847 std::vector<std::unique_ptr<ClientThread>> drained;
848 {
849 std::lock_guard<std::mutex> lock(client_threads_mutex_);
850 drained = std::move(client_threads_);
851 client_threads_.clear();
852 }
853 for (auto& ct : drained) {
854 if (ct->fd >= 0) { ::shutdown(ct->fd, SHUT_RDWR); }
855 }
856 for (auto& ct : drained) {
857 if (ct->thread.joinable()) { ct->thread.join(); }
858 }
859
860 // Clean up socket file
861 std::error_code ec;
862 std::filesystem::remove(socket_path_, ec);
863
864 // Release our claim on the canonical path so a later handle in
865 // this process can re-bind. Use the exact string we inserted in
866 // start(); recomputing weakly_canonical here could differ if the
867 // path was deleted mid-run.
868 if (!bound_canonical_.empty()) {
869 std::lock_guard lk(s_bound_sockets_mu);
870 s_bound_sockets.erase(bound_canonical_);
871 bound_canonical_.clear();
872 }
873}
874
887void ExternalBridge::reap_finished_clients_locked() {
888 auto it = client_threads_.begin();
889 while (it != client_threads_.end()) {
890 if ((*it)->finished.load()) {
891 if ((*it)->thread.joinable()) { (*it)->thread.join(); }
892 it = client_threads_.erase(it);
893 } else {
894 ++it;
895 }
896 }
897}
898
924void ExternalBridge::accept_loop() {
925 while (running_.load()) {
926 struct pollfd pfd{};
927 pfd.fd = listen_fd_;
928 pfd.events = POLLIN;
929
930 int rc = poll(&pfd, 1, 500); // 500ms timeout for shutdown check
931 if (rc <= 0) { continue; }
932
933 int client_fd = accept(listen_fd_, nullptr, nullptr);
934 if (client_fd < 0) { continue; }
935
936 if (!peer_uid_matches(client_fd)) {
937 ::close(client_fd); // v2.1.7 (gh#34): cross-uid attempt
938 continue;
939 }
940
941 logger->info("External MCP client connected (fd={})", client_fd);
942
943 auto ct = std::make_unique<ClientThread>();
944 ct->fd = client_fd;
945 // Capture a raw pointer for the thread body so the unique_ptr
946 // can stay in client_threads_ without aliasing.
947 ClientThread* raw = ct.get();
948 // gh#59 (v2.3.1): propagate the handle log scope into the
949 // per-client thread so its `serve_client` logs route to the
950 // owning handle's session.log.
951 int log_id = handle_ ? handle_->log_id : 0;
952 ct->thread = std::thread([this, raw, log_id]() {
953 entropic::log::HandleLogScope scope(log_id);
954 serve_client(raw->fd);
955 ::close(raw->fd);
956 raw->fd = -1;
957 logger->info("External MCP client disconnected");
958 raw->finished.store(true);
959 });
960
961 std::lock_guard<std::mutex> lock(client_threads_mutex_);
962 client_threads_.push_back(std::move(ct));
963 reap_finished_clients_locked();
964 }
965}
966
974static std::string read_line(int fd) {
975 std::string line;
976 char c;
977 while (true) {
978 ssize_t n = read(fd, &c, 1);
979 if (n <= 0) { return {}; }
980 if (c == '\n') { return line; }
981 line += c;
982 }
983}
984
995void ExternalBridge::serve_client(int client_fd) {
996 subscribe(client_fd);
997 struct Unsub {
998 ExternalBridge* self;
999 int fd;
1000 ~Unsub() { self->unsubscribe(fd); }
1001 } guard{this, client_fd};
1002
1003 while (running_.load()) {
1004 auto line = read_line(client_fd);
1005 if (line.empty()) { break; }
1006
1007 auto response = dispatch(line, client_fd);
1008 if (response.empty()) { continue; } // notification — no reply
1009 response += '\n';
1010
1011 ssize_t written = write(client_fd, response.c_str(),
1012 response.size());
1013 if (written < 0) { break; }
1014 }
1015}
1016
1023static json initialize_result() {
1024 return {
1025 {"protocolVersion", "2025-06-18"},
1026 {"serverInfo", {{"name", "entropic"},
1027 {"version", entropic_version()}}},
1028 {"capabilities", {{"tools", json::object()}}}
1029 };
1030}
1031
1045std::string ExternalBridge::dispatch(
1046 const std::string& request, int client_fd) {
1047 auto req = json::parse(request, nullptr, false);
1048 // Parse error or notification (no id) → no dispatch
1049 if (req.is_discarded() || !req.contains("id")) {
1050 return req.is_discarded()
1051 ? rpc_err(nullptr, -32700, "Parse error")
1052 : std::string{};
1053 }
1054
1055 json id = req["id"];
1056 std::string method = req.value("method", std::string{});
1057 json params = req.value("params", json::object());
1058 json result;
1059
1060 if (method == "initialize") { result = initialize_result(); }
1061 else if (method == "tools/list") { result = {{"tools", tool_definitions()}}; }
1062 else if (method == "tools/call") {
1063 auto id_str = id.is_string() ? id.get<std::string>()
1064 : id.dump();
1065 result = dispatch_tool(handle_, this, params, client_fd, id_str);
1066 }
1067 else if (method == "shutdown" || method == "exit") { result = json::object(); }
1068 else { return rpc_err(id, -32601, "Unknown method: " + method); }
1069 return rpc_ok(id, result);
1070}
1071
1072// ── Async task support ───────────────────────────────────
1073
1079static void phase_observer_cb(int state, void* ud) {
1080 auto* self = static_cast<ExternalBridge*>(ud);
1081 if (state != ENTROPIC_AGENT_STATE_VERIFYING) { return; }
1082 std::lock_guard<std::mutex> lock(self->tasks_mutex_);
1083 // E5+E6 (2.1.0): discard stale callbacks fired after detach_phase_observer
1084 // incremented observer_gen_. attached_gen_ was captured at attach time.
1085 if (self->observer_call_is_stale()) { return; }
1086 auto it = self->tasks_for_cancel().find(self->active_task_id_for_observer());
1087 if (it == self->tasks_for_cancel().end()) { return; }
1088 // First VERIFYING = "validating"; subsequent VERIFYING transitions
1089 // on the same task indicate revision retries → "revising".
1090 it->second.phase = (it->second.phase == "validating"
1091 || it->second.phase == "revising")
1092 ? "revising" : "validating";
1093}
1094
1106void ExternalBridge::attach_phase_observer(const std::string& task_id) {
1107 {
1108 std::lock_guard<std::mutex> lock(tasks_mutex_);
1109 active_task_id_ = task_id;
1110 attached_gen_ = ++observer_gen_;
1111 }
1113}
1114
1128 {
1129 std::lock_guard<std::mutex> lock(tasks_mutex_);
1130 ++observer_gen_;
1131 active_task_id_.clear();
1132 }
1133 entropic_set_state_observer(handle_, nullptr, nullptr);
1134}
1135
1150 const std::string& prompt,
1151 const std::string& task_id,
1152 int client_fd) {
1153 {
1154 std::lock_guard<std::mutex> lock(tasks_mutex_);
1155 AsyncTask t;
1156 t.status = "queued";
1157 t.phase = "queued";
1158 t.created = std::chrono::steady_clock::now();
1159 tasks_[task_id] = std::move(t);
1160 }
1161
1162 // gh#59 (v2.3.1): the async-ask worker runs entropic_run on this
1163 // handle's behalf — scope its logs to the owning handle.
1164 int log_id = handle_ ? handle_->log_id : 0;
1165 std::thread([this, prompt, task_id, client_fd, log_id]() {
1166 entropic::log::HandleLogScope scope(log_id);
1167 update_task_phase(task_id, "running", "running");
1168 attach_phase_observer(task_id);
1169
1170 char* result_json = nullptr;
1171 auto err = entropic_run(handle_, prompt.c_str(), &result_json);
1172
1174
1175 auto final_state = derive_async_final_state(
1176 handle_, err, result_json);
1177
1178 {
1179 std::lock_guard<std::mutex> lock(tasks_mutex_);
1180 auto it = tasks_.find(task_id);
1181 if (it != tasks_.end()) {
1182 it->second.status = final_state.status;
1183 it->second.phase = final_state.phase;
1184 it->second.result = final_state.text;
1185 }
1186 // Issue #12 (v2.1.4): write sentinel UNDER tasks_mutex_,
1187 // before the MCP notification fires. Any external monitor
1188 // reacting to the sentinel can immediately call
1189 // entropic.ask_status and see the same terminal state.
1190 write_sentinel(task_id, final_state.status);
1191 }
1192 auto status = final_state.status;
1193
1194 // Issue #4 (v2.1.2, parts A+B): emit the spec-defined
1195 // ``notifications/progress`` method instead of the previous
1196 // non-spec ``notifications/ask_complete``. MCP-compliant
1197 // clients are obligated to drain ``notifications/progress``
1198 // (it's in the documented set); some clients silently
1199 // buffered or stalled on the unknown method name, which
1200 // combined with the bridge's blocking broadcast (fixed in
1201 // part C of this release) produced the deadlock observed
1202 // against entropic-explorer ↔ Claude Code in the field.
1203 //
1204 // ``progressToken`` is the ``task_id`` so the consumer can
1205 // correlate the notification back to the originating
1206 // ``entropic.ask`` response (which carried the same
1207 // ``task_id``). The result body is NO LONGER shipped inline
1208 // — consumers fetch via ``entropic.ask_status``. This caps
1209 // notification size at ~200 bytes regardless of generated
1210 // output, eliminating a real DoS surface (a 50KB result
1211 // would otherwise flood the broadcast write path on every
1212 // subscriber). ``status`` rides in ``message`` so consumers
1213 // can branch on done / error / cancelled without an extra
1214 // round-trip just to learn which result kind to fetch.
1215 json notif = {
1216 {"jsonrpc", "2.0"},
1217 {"method", "notifications/progress"},
1218 {"params", {
1219 {"progressToken", task_id},
1220 {"progress", 100},
1221 {"total", 100},
1222 {"message", status}
1223 }}
1224 };
1226
1228 logger->info("Async task {} completed: {}", task_id, status);
1229 }).detach();
1230}
1231
1244 std::lock_guard<std::mutex> lock(subscribers_mutex_);
1245 subscribers_.insert(fd);
1246}
1247
1255 std::lock_guard<std::mutex> lock(subscribers_mutex_);
1256 subscribers_.erase(fd);
1257}
1258
1293 auto payload = notif.dump() + "\n";
1294
1295 std::vector<int> snapshot;
1296 {
1297 std::lock_guard<std::mutex> lock(subscribers_mutex_);
1298 snapshot.assign(subscribers_.begin(), subscribers_.end());
1299 }
1300
1301 std::vector<int> dead;
1302 for (int fd : snapshot) {
1303 ssize_t rc = ::send(fd, payload.c_str(), payload.size(),
1304 MSG_DONTWAIT | MSG_NOSIGNAL);
1305 if (rc < 0) {
1306 // EAGAIN / EWOULDBLOCK: peer recv buffer full (slow consumer).
1307 // EBADF / EPIPE / ECONNRESET: peer closed or otherwise dead.
1308 // All collapse to "drop" — the long-term per-subscriber
1309 // queue lives in proposal P2-20260429-001.
1310 logger->warn("Subscriber fd {} send failed (errno={}) — dropping",
1311 fd, errno);
1312 dead.push_back(fd);
1313 } else if (static_cast<size_t>(rc) < payload.size()) {
1314 logger->warn("Subscriber fd {} partial send ({}/{}) — dropping",
1315 fd, rc, payload.size());
1316 dead.push_back(fd);
1317 }
1318 }
1319
1320 if (!dead.empty()) {
1321 std::lock_guard<std::mutex> lock(subscribers_mutex_);
1322 for (int fd : dead) { subscribers_.erase(fd); }
1323 }
1324}
1325
1334void ExternalBridge::update_task_phase(const std::string& task_id,
1335 const std::string& status,
1336 const std::string& phase) {
1337 std::lock_guard<std::mutex> lock(tasks_mutex_);
1338 auto it = tasks_.find(task_id);
1339 if (it == tasks_.end()) { return; }
1340 it->second.status = status;
1341 it->second.phase = phase;
1342}
1343
1357 auto cutoff = std::chrono::steady_clock::now()
1358 - std::chrono::minutes(15);
1359 auto sentinel_dir = async_sentinel_dir();
1360 std::lock_guard<std::mutex> lock(tasks_mutex_);
1361 for (auto it = tasks_.begin(); it != tasks_.end(); ) {
1362 if (it->second.created < cutoff) {
1363 if (!sentinel_dir.empty()) {
1364 for (const char* suffix :
1365 {".done", ".failed", ".cancelled"}) {
1366 std::error_code ec;
1367 std::filesystem::remove(
1368 sentinel_dir / (it->first + suffix), ec);
1369 }
1370 }
1371 it = tasks_.erase(it);
1372 } else {
1373 ++it;
1374 }
1375 }
1376}
1377
1390std::filesystem::path ExternalBridge::async_sentinel_dir() const {
1391 std::filesystem::path root = async_sentinel_root_override_;
1392 if (root.empty() && handle_ != nullptr) {
1393 root = handle_->config.log_dir;
1394 }
1395 return root.empty() ? std::filesystem::path{} : (root / "async");
1396}
1397
1404 const std::filesystem::path& root) {
1405 async_sentinel_root_override_ = root;
1406}
1407
1414 const std::string& status) {
1415 const char* suffix = ".done";
1416 if (status == "error") {
1417 suffix = ".failed";
1418 } else if (status == "cancelled") {
1419 suffix = ".cancelled";
1420 }
1421 return suffix;
1422}
1423
1439void ExternalBridge::write_sentinel(const std::string& task_id,
1440 const std::string& status) {
1441 auto dir = async_sentinel_dir();
1442 if (dir.empty()) { return; }
1443 std::error_code ec;
1444 std::filesystem::create_directories(dir, ec);
1445 if (ec) {
1446 logger->warn("write_sentinel: mkdir {} failed: {}",
1447 dir.string(), ec.message());
1448 return;
1449 }
1450 auto path = dir / (task_id + sentinel_suffix_for_status(status));
1451 std::ofstream out(path);
1452 if (!out.is_open()) {
1453 logger->warn("write_sentinel: open {} failed", path.string());
1454 return;
1455 }
1456 out << status << '\n';
1457}
1458
1459} // namespace entropic
Unix socket MCP bridge for external client access.
bool start()
Start the background accept loop.
void cleanup_expired_tasks()
Remove tasks older than TTL from the registry.
void stop()
Stop the accept loop and close the socket.
void unsubscribe(int fd)
Remove an fd from the subscriber set.
std::mutex tasks_mutex_
Async task mutex (public for dispatch_tool access).
~ExternalBridge()
Destructor — stop if running.
void detach_phase_observer()
Clear the phase observer installed by attach_phase_observer.
nlohmann::json handle_ask_status(const nlohmann::json &args)
Handle entropic.ask_status — check async task state.
std::filesystem::path async_sentinel_dir() const
Sentinel directory (lazy: returns empty path until the engine's log_dir is configured).
ExternalBridge(entropic_handle_t handle, const ExternalMCPConfig &config, const std::filesystem::path &project_dir)
Construct with engine handle and config.
void run_async_ask(const std::string &prompt, const std::string &task_id, int client_fd)
Run an async entropic.ask in a detached background thread.
bool ask_streaming() const
Whether entropic.ask routes through entropic_run_streaming.
void broadcast_notification(const nlohmann::json &notif)
Write a JSON-RPC notification to every subscribed fd.
void set_async_sentinel_root(const std::filesystem::path &root)
Override the async sentinel root directory.
void attach_phase_observer(const std::string &task_id)
Run an async entropic.ask in a background thread.
void write_sentinel(const std::string &task_id, const std::string &status)
Write the sentinel file for an async task completion.
void subscribe(int fd)
Add a connected fd to the subscriber set.
void update_task_phase(const std::string &task_id, const std::string &status, const std::string &phase)
Update status/phase for a tracked task atomically.
std::unordered_map< std::string, AsyncTask > & tasks_for_cancel()
Mutable accessor to the task registry.
gh#59 (v2.3.1): RAII guard — sets thread's current handle_id.
Definition logging.h:159
Private definition of the entropic_engine struct.
Public C API for the Entropic inference engine.
ENTROPIC_EXPORT entropic_error_t entropic_set_state_observer(entropic_handle_t handle, void(*observer)(int state, void *user_data), void *user_data)
Register an engine state-change observer.
ENTROPIC_EXPORT entropic_error_t entropic_context_count(entropic_handle_t handle, size_t *count)
Get the number of messages in the conversation.
ENTROPIC_EXPORT entropic_error_t entropic_context_clear(entropic_handle_t handle)
Clear conversation history, starting a new session.
ENTROPIC_EXPORT entropic_error_t entropic_run(entropic_handle_t handle, const char *input, char **result_json)
Synchronous agentic loop.
ENTROPIC_EXPORT entropic_error_t entropic_metrics_json(entropic_handle_t handle, char **out)
Get loop metrics from the most recent run as JSON.
ENTROPIC_EXPORT const char * entropic_version(void)
Get the library version string.
ENTROPIC_EXPORT entropic_error_t entropic_interrupt(entropic_handle_t handle)
Interrupt a running generation.
ENTROPIC_EXPORT void entropic_free(void *ptr)
Free memory allocated by the engine or entropic_alloc().
ENTROPIC_EXPORT entropic_error_t entropic_context_get(entropic_handle_t handle, char **messages_json)
Get the current conversation history as a JSON array.
ENTROPIC_EXPORT entropic_error_t entropic_run_streaming(entropic_handle_t handle, const char *input, void(*on_token)(const char *token, size_t len, void *user_data), void *user_data, int *cancel_flag)
Streaming agentic loop with token callback.
@ ENTROPIC_AGENT_STATE_VERIFYING
Post-generation verification.
Definition enums.h:45
entropic_error_t
Error codes returned by all C API functions.
Definition error.h:37
@ ENTROPIC_OK
Success.
Definition error.h:38
@ ENTROPIC_ERROR_CANCELLED
Operation cancelled via cancel token.
Definition error.h:50
@ ENTROPIC_ERROR_INTERRUPTED
Operation interrupted via entropic_interrupt (v1.8.9)
Definition error.h:64
ENTROPIC_EXPORT const char * entropic_last_error(entropic_handle_t handle)
Get the last error message for a handle.
Definition entropic.cpp:155
Unix socket MCP bridge — exposes a running engine to external clients.
Operator-visible final-text extraction for the external bridge.
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
Activate model on GPU (WARM → ACTIVE).
static std::string generate_task_id()
Generate a simple UUID-like task ID.
static bool any_cancelling_left(ExternalBridge *bridge)
True while any task is still phase=cancelling.
static json handle_ask(entropic_handle_t handle, const json &args, int client_fd, const std::string &call_id)
Handle entropic.ask — stream tokens then return final text.
std::filesystem::path compute_socket_path(const std::filesystem::path &project_dir)
Compute project-unique Unix socket path for self-detection.
static const char * sentinel_suffix_for_status(const std::string &status)
Map a terminal status string to a sentinel filename suffix.
static int create_listen_socket(const std::filesystem::path &path)
Create, bind, and listen on a unix domain socket.
@ ok
Tool dispatched, returned non-empty content.
static void phase_observer_cb(int state, void *ud)
State observer that projects VERIFYING onto task phase.
static bool socket_path_safe(const std::filesystem::path &path)
Reject a pre-existing path that is a symlink or non-socket.
static json handle_count(entropic_handle_t handle)
Handle entropic.context_count.
static bool peer_uid_matches(int client_fd)
Validate that the connecting peer shares the engine's UID.
static json tool_text(const std::string &text)
Wrap text in MCP tool result shape.
static bool mark_tasks_cancelling(ExternalBridge *bridge)
Mark every queued/running task as cancelling.
@ request
GenerationParams::grammar (COMMON_GRAMMAR_TYPE_USER)
@ count
Sentinel — MUST remain last.
static void send_progress(int fd, const std::string &token_text, const std::string &progress_token)
Send an MCP progress notification with a text token.
static bool bind_and_listen(int fd, const std::filesystem::path &path)
Bind+listen on an AF_UNIX socket, applying 0600 perms.
static json final_answer_from_context(entropic_handle_t handle)
Read the live conversation and render the operator-visible answer.
static json handle_clear(entropic_handle_t handle, ExternalBridge *bridge)
entropic.context_clear MCP tool handler.
static void cancel_inflight_async_tasks(entropic_handle_t handle, ExternalBridge *bridge)
Cancel any async tasks currently running on the bridge.
static AsyncFinalState derive_async_final_state(entropic_handle_t handle, entropic_error_t err, char *result_json)
Translate entropic_run's return code into a final task state.
static json tool_definitions()
MCP tool definitions exposed by the bridge.
static void write_json_line(int fd, const json &msg)
Write a JSON-RPC line to a socket fd.
static json handle_ask_plain(entropic_handle_t handle, const json &args)
Handle entropic.ask — non-streaming path (gh#115, v2.9.12).
static std::string rpc_err(const json &id, int code, const std::string &msg)
Build a JSON-RPC error response.
static json initialize_result()
Build the MCP initialize response payload.
static json dispatch_tool(entropic_handle_t handle, ExternalBridge *bridge, const json &params, int client_fd, const std::string &call_id)
Dispatch a tools/call to the appropriate handler.
static std::string read_line(int fd)
Read one newline-delimited line from a socket fd.
static json handle_status(entropic_handle_t handle)
Handle entropic.status.
static json dispatch_ask(entropic_handle_t handle, ExternalBridge *bridge, const json &args, int client_fd, const std::string &call_id)
Route entropic.ask — sync (streaming) or async.
static std::string rpc_ok(const json &id, const json &result)
Build a JSON-RPC success response.
static void prepare_socket_dir(const std::filesystem::path &parent)
Prepare the socket containing directory with 0700 perms.
Handle entropic.context_clear.
std::string text
result or error message
std::string status
done | error | cancelled
std::string phase
done | failed | cancelled
Async task state for background entropic.ask runs.
std::string phase
queued, running, running:<tier>, done, failed, cancelled (P1-5)
std::chrono::steady_clock::time_point created
For TTL cleanup.
std::string status
queued | running | done | error | cancelled (2.0.6-rc16)
External MCP server configuration (Entropic-as-server).
Definition config.h:642
std::optional< std::filesystem::path > socket_path
Socket path (nullopt = derived)
Definition config.h:644
std::filesystem::path log_dir
Session log directory (session.log + session_model.log).
Definition config.h:1023
Engine handle struct — owns all subsystems.
int log_id
gh#59 (v2.3.1): unique handle id for per-handle log routing via entropic::log::HandleAwareSink.
entropic::ParsedConfig config
Parsed config.