Entropic 2.14.3
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
15#include <entropic/entropic.h>
17
18#include "engine_handle.h"
19#include "final_text.h" // gh#130 (v2.10.2)
20#include "tool_namespace.h" // gh#145 (v2.12.0)
21
22#include <nlohmann/json.hpp>
23
24#include <atomic>
25#include <cerrno>
26#include <algorithm>
27#include <chrono>
28#include <condition_variable>
29#include <cstring>
30#include <filesystem>
31#include <fstream>
32#include <functional>
33#include <mutex>
34#include <sstream>
35#include <thread>
36#include <unordered_map>
37#include <unordered_set>
38#include <unistd.h>
39#include <sys/socket.h>
40#include <sys/stat.h>
41#include <sys/un.h>
42#include <poll.h>
43
44using json = nlohmann::json;
45
46namespace entropic {
47
48static auto logger = entropic::log::get("mcp.external_bridge");
49
50// gh#58: process-wide set of socket paths currently bound by an
51// ExternalBridge in this process. Without this, a second handle whose
52// project_dir hashes to the same socket path as a first handle would
53// unlink the live socket out from under handle #1 (see the
54// std::filesystem::remove call in create_listen_socket). With this
55// guard, the second handle declines to start its bridge instead.
56static std::mutex s_bound_sockets_mu;
57static std::unordered_set<std::string> s_bound_sockets;
58
59// Forward declaration
60std::filesystem::path compute_socket_path(
61 const std::filesystem::path& project_dir);
62
63// ── JSON-RPC helpers ─────────────────────────────────────
64
73static std::string rpc_ok(const json& id, const json& result) {
74 json r = {{"jsonrpc", "2.0"}, {"id", id}, {"result", result}};
75 return r.dump();
76}
77
87static std::string rpc_err(const json& id, int code,
88 const std::string& msg) {
89 json r = {{"jsonrpc", "2.0"}, {"id", id},
90 {"error", {{"code", code}, {"message", msg}}}};
91 return r.dump();
92}
93
101static json tool_text(const std::string& text) {
102 return {{"content", json::array({{{"type", "text"}, {"text", text}}})}};
103}
104
105// ── Tool definitions ─────────────────────────────────────
106
117namespace tool_suffix {
118constexpr const char* kAsk = "ask";
119constexpr const char* kAskStatus = "ask_status";
120constexpr const char* kStatus = "status";
121constexpr const char* kContextClear = "context_clear";
122constexpr const char* kContextCount = "context_count";
123constexpr const char* kRelease = "release"; // v2.14.3
124} // namespace tool_suffix
125
141static std::string describe(const ExternalMCPConfig& cfg,
142 const char* suffix,
143 const char* fallback) {
144 auto it = cfg.tool_descriptions.find(suffix);
145 if (it != cfg.tool_descriptions.end() && !it->second.empty()) {
146 return it->second;
147 }
148 return fallback;
149}
150
165 const char* suffix,
166 const char* fallback) {
167 return {{"name", facade::qualify_tool_name(cfg.tool_prefix, suffix)},
168 {"description", describe(cfg, suffix, fallback)},
169 {"inputSchema", {{"type", "object"},
170 {"properties", json::object()}}}};
171}
172
187 const char* suffix,
188 const char* fallback) {
189 return {{"name", facade::qualify_tool_name(cfg.tool_prefix, suffix)},
190 {"description", describe(cfg, suffix, fallback)},
191 {"inputSchema", {
192 {"type", "object"},
193 {"properties", {
194 {"session", {{"type", "string"},
195 {"description",
196 "Conversation to act on; omit for the "
197 "shared default session."}}}
198 }}
199 }}};
200}
201
209static json ask_tool(const ExternalMCPConfig& cfg) {
210 return {
211 {"name", facade::qualify_tool_name(cfg.tool_prefix,
212 tool_suffix::kAsk)},
213 {"description",
214 describe(cfg, tool_suffix::kAsk,
215 "Submit a prompt to the running entropic engine. "
216 "Set async=true to return immediately with a task_id; "
217 "the engine pushes a notification when done.")},
218 {"inputSchema", {
219 {"type", "object"},
220 {"properties", {
221 {"prompt", {{"type", "string"},
222 {"description", "User message"}}},
223 {"async", {{"type", "boolean"},
224 {"description", "Run asynchronously"},
225 {"default", false}}},
226 // gh#144: optional, and NOT in `required`. Absent means the
227 // shared default session, which is exactly pre-2.12.0
228 // behaviour for every existing client.
229 {"session", {{"type", "string"},
230 {"description",
231 "Conversation to run under. Callers passing "
232 "the same value share a conversation; "
233 "omit for the shared default session."}}},
234 // gh#205 (v2.14.0): both optional; absent = pre-2.14.0.
235 {"tier", {{"type", "string"},
236 {"description",
237 "Tier or identity to run this turn under; omit "
238 "to route as usual."}}},
239 {"workspace", {{"type", "string"},
240 {"description",
241 "Repository this session works in: an "
242 "absolute directory (a workspace is created "
243 "for it on first use) or the name of a "
244 "workspace the host created. Fixed for the "
245 "session once it has messages."}}}
246 }},
247 {"required", json::array({"prompt"})}
248 }}};
249}
250
259 return {
260 {"name", facade::qualify_tool_name(cfg.tool_prefix,
261 tool_suffix::kAskStatus)},
262 {"description",
263 describe(cfg, tool_suffix::kAskStatus,
264 "Check status of an async ask task.")},
265 {"inputSchema", {
266 {"type", "object"},
267 {"properties", {{"task_id", {
268 {"type", "string"},
269 {"description", "Task ID from async ask"}
270 }}}},
271 {"required", json::array({"task_id"})}
272 }}};
273}
274
288 return json::array({
289 ask_tool(cfg),
291 no_args_tool(cfg, tool_suffix::kStatus,
292 "Engine version and message count."),
293 session_scoped_tool(cfg, tool_suffix::kContextClear,
294 "Clear conversation history."),
295 session_scoped_tool(cfg, tool_suffix::kContextCount,
296 "Return the message count."),
297 // v2.14.3: the one way an MCP client can give the card back.
298 no_args_tool(cfg, tool_suffix::kRelease,
299 "Unload every loaded model to free GPU/host memory. "
300 "Sessions and their conversations are kept; the next "
301 "ask reloads the model. Call when you are done for "
302 "now. Refused while a turn is running — retry then."),
303 });
304}
305
306// ── Tool handlers ────────────────────────────────────────
307
308// gh#130 (v2.10.2): extract_final_text moved to the private facade header
309// final_text.h so its selection rule is unit-testable — this was a `static`
310// helper with no reachable test, and it shipped a scanning bug that returned
311// "(no response)" to operators while the answer sat earlier in the same
312// conversation. See facade_text::extract_final_text for the rule.
313
321static void write_json_line(int fd, const json& msg) {
322 auto s = msg.dump() + "\n";
323 ::write(fd, s.c_str(), s.size());
324}
325
337static void send_progress(int fd, const std::string& token_text,
338 const json& progress_token, uint64_t seq) {
339 // v2.14.0: MCP's `progress` is a NUMBER that increases with each
340 // notification; the token text rides in `message`. Pre-2.14.0 put the
341 // text in `progress`, which spec-validating clients reject.
342 json notif = {
343 {"jsonrpc", "2.0"},
344 {"method", "notifications/progress"},
345 {"params", {
346 {"progressToken", progress_token},
347 {"progress", seq},
348 {"message", token_text}
349 }}
350 };
352}
353
372 const std::string& session_key) {
373 char* msgs_json = nullptr;
374 entropic_session_context_get(handle, session_key.c_str(), &msgs_json);
375 auto answer = facade_text::final_text_or_reason(msgs_json);
377 return tool_text(answer);
378}
379
392 const std::string& session_key,
393 const std::string& tier,
394 const std::string& prompt,
395 char** result_json) {
396 return tier.empty()
397 ? entropic_run_session(handle, session_key.c_str(), prompt.c_str(),
399 : entropic_run_session_as(handle, session_key.c_str(), tier.c_str(),
400 prompt.c_str(), result_json);
401}
402
416 entropic_handle_t handle, const std::string& session_key,
417 const std::string& tier, const std::string& prompt,
418 void (*on_token)(const char*, size_t, void*), void* ud) {
419 return tier.empty()
420 ? entropic_run_session_streaming(handle, session_key.c_str(),
421 prompt.c_str(), on_token, ud, nullptr)
422 : entropic_run_session_streaming_as(handle, session_key.c_str(),
423 tier.c_str(), prompt.c_str(),
424 on_token, ud, nullptr);
425}
426
442static json handle_ask_plain(entropic_handle_t handle, const json& args,
443 const std::string& session_key) {
444 auto it = args.find("prompt");
445 if (it == args.end() || !it->is_string()) {
446 return tool_text("error: missing 'prompt' argument");
447 }
448 char* result_json = nullptr;
449 auto err = run_plain_turn(handle, session_key,
450 args.value("tier", std::string{}), // gh#205
451 it->get<std::string>(), &result_json);
452 if (err != ENTROPIC_OK) {
453 const char* msg = entropic_last_error(handle);
455 return tool_text(std::string("error: ") + (msg ? msg : "unknown"));
456 }
457 // Resolve before the free — final_text_or_reason reads that buffer.
458 auto answer = facade_text::final_text_or_reason(result_json);
460 return tool_text(answer);
461}
462
480static json handle_ask(entropic_handle_t handle, const json& args,
481 const std::string& session_key,
482 int client_fd, const json& progress_token) {
483 auto it = args.find("prompt");
484 if (it == args.end() || !it->is_string()) {
485 return tool_text("error: missing 'prompt' argument");
486 }
487 std::string prompt = it->get<std::string>();
488
489 // Stream tokens as progress notifications
490 struct StreamCtx { int fd; json token; uint64_t seq; };
492 auto on_token = [](const char* tok, size_t len, void* ud) {
493 auto* ctx = static_cast<StreamCtx*>(ud);
494 send_progress(ctx->fd, std::string(tok, len), ctx->token, ++ctx->seq);
495 };
496 // gh#205: a requested tier locks the turn; absent routes as before.
497 auto err = run_stream_turn(handle, session_key,
498 args.value("tier", std::string{}), prompt,
499 on_token, &sctx);
500 if (err != ENTROPIC_OK) {
501 const char* msg = entropic_last_error(handle);
502 return tool_text(std::string("error: ") + (msg ? msg : "unknown"));
503 }
504
505 return final_answer_from_context(handle, session_key);
506}
507
528 const ExternalBridge* bridge) {
529 size_t count = 0;
531 std::ostringstream os;
532 os << "entropic " << entropic_version()
533 << "\nmessages: " << count
534 << "\nbusy: " << (bridge->turn_in_flight() ? "true" : "false")
535 << "\nqueue_depth: " << bridge->turn_queue_depth();
536 // gh#144 (v2.12.0): which callers this host is serving, and how much
537 // history each holds.
538 char* sessions = nullptr;
540 && sessions != nullptr) {
541 os << "\nsessions: " << sessions;
543 }
544 // Metrics + per-tier breakdown (P2-15 follow-up, 2.0.6-rc16.2)
545 char* mjson = nullptr;
547 && mjson != nullptr) {
548 os << "\nmetrics: " << mjson;
550 }
551 return tool_text(os.str());
552}
553
584 std::string status;
585 std::string phase;
586 std::string text;
587};
588
604 entropic_handle_t handle,
606 char* result_json) {
610 const char* msg = entropic_last_error(handle);
611 s.text = msg ? msg : "cancelled";
612 s.status = "cancelled";
613 s.phase = "cancelled";
614 } else if (err != ENTROPIC_OK) {
615 const char* msg = entropic_last_error(handle);
616 s.text = msg ? msg : "unknown error";
617 s.status = "error";
618 s.phase = "failed";
619 } else {
620 // gh#130 (v2.10.2): same selection rule as the sync paths. This one
621 // had no fallback at all, so a stalled async ask reported
622 // status="done" with empty text — even less diagnosable than the
623 // sync path's "(no response)".
624 s.text = facade_text::final_text_or_reason(result_json);
626 s.status = "done";
627 s.phase = "done";
628 }
629 return s;
630}
631
641 const std::string& session_key) {
642 std::lock_guard<std::mutex> lock(bridge->tasks_mutex_);
643 bool any = false;
644 for (auto& [_, task] : bridge->tasks_for_cancel()) {
645 // v2.14.0: only the cleared session's tasks.
646 if (task.session_key != session_key) { continue; }
647 if (task.status == "queued" || task.status == "running") {
648 task.status = "cancelled";
649 task.phase = "cancelling";
650 any = true;
651 }
652 }
653 return any;
654}
655
665 const std::string& session_key) {
666 std::lock_guard<std::mutex> lock(bridge->tasks_mutex_);
667 bool left = false;
668 for (auto& [_, task] : bridge->tasks_for_cancel()) {
669 left = left || (task.session_key == session_key
670 && task.phase == "cancelling");
671 }
672 return left;
673}
674
693 const std::string& session_key) {
694 if (bridge == nullptr) { return; }
695 // v2.14.0: THIS session's run only. entropic_interrupt is handle-wide —
696 // clearing session A used to cancel session B's turn.
697 entropic_interrupt_session(handle, session_key.c_str());
698 if (!mark_tasks_cancelling(bridge, session_key)) { return; }
699 for (int i = 0; i < 20 && any_cancelling_left(bridge, session_key); ++i) {
700 std::this_thread::sleep_for(std::chrono::milliseconds(50));
701 }
702 bridge->detach_phase_observer(); // bump gen; silences any stale observer
703}
704
719 const std::string& session_key) {
720 cancel_inflight_async_tasks(handle, bridge, session_key);
721 // gh#144 (v2.12.0): clear ONLY the named session. The unscoped clear
722 // wiped every caller's context, which the issue correctly observed "is
723 // not isolation" — with two callers active there is no ordering in which
724 // it is correct.
725 auto err = entropic_session_context_clear(handle, session_key.c_str());
726 if (err != ENTROPIC_OK) {
727 return tool_text("error: clear failed");
728 }
729 return tool_text(session_key.empty()
730 ? "conversation cleared"
731 : "conversation cleared for session '" + session_key + "'");
732}
733
742 const std::string& session_key) {
743 size_t count = 0;
744 // gh#144 (v2.12.0): count only the named session.
745 entropic_session_context_count(handle, session_key.c_str(), &count);
746 return tool_text(std::to_string(count));
747}
748
749// ── Async ask status ─────────────────────────────────────
750
762json ExternalBridge::handle_ask_status(const json& args) {
763 auto tid = args.value("task_id", std::string{});
764 std::lock_guard<std::mutex> lock(tasks_mutex_);
765 auto it = tasks_.find(tid);
766 if (it == tasks_.end()) {
767 return tool_text("error: unknown task_id");
768 }
769 json status = {{"status", it->second.status},
770 {"phase", it->second.phase}};
771 if (!it->second.result.empty()) {
772 auto key = (it->second.status == "error") ? "error" : "result";
773 status[key] = it->second.result;
774 }
775 return tool_text(status.dump());
776}
777
778// ── UUID generation ──────────────────────────────────────
779
786static std::string generate_task_id() {
787 static std::atomic<uint64_t> counter{0};
788 auto n = counter.fetch_add(1);
789 auto t = std::chrono::steady_clock::now().time_since_epoch().count();
790 std::ostringstream ss;
791 ss << std::hex << (t ^ (n * 2654435761ULL));
792 return "task-" + ss.str();
793}
794
795namespace {
796
798constexpr long kTurnWaitTimeoutMs = 300000; // 5 minutes
799
805class TurnTicket {
806public:
820 TurnTicket(ExternalBridge* b, std::string session_key)
821 : bridge_(b), session_(std::move(session_key)),
822 held_(b->begin_turn_wait(kTurnWaitTimeoutMs, &waited_ms_, session_)) {
823 logger->info("[turn] session '{}' {} after {} ms", session_,
824 held_ ? "admitted" : "gave up", waited_ms_);
825 }
826
832 ~TurnTicket() { if (held_) { bridge_->end_turn_wait(session_); } }
833
834 TurnTicket(const TurnTicket&) = delete;
835 TurnTicket& operator=(const TurnTicket&) = delete;
836
843 bool held() const { return held_; }
844
851 long waited_ms() const { return waited_ms_; }
852
853private:
854 ExternalBridge* bridge_;
855 std::string session_;
856 long waited_ms_ = 0;
857 bool held_;
858};
859
865json turn_timeout_result(const ExternalBridge* bridge, long waited_ms) {
866 std::ostringstream os;
867 os << "error: engine busy — waited " << waited_ms << " ms with "
868 << bridge->turn_queue_depth() << " request(s) still ahead. "
869 << "Another caller's turn is still running; retry shortly.";
870 return tool_text(os.str());
871}
872
873} // namespace
874
875// ── Dispatch ─────────────────────────────────────────────
876
885static std::string bound_workspace(entropic_handle_t handle,
886 const std::string& session_key) {
887 std::lock_guard<std::mutex> guard(handle->workspace_mutex);
888 auto it = handle->session_workspace.find(session_key);
889 return it == handle->session_workspace.end() ? std::string{} : it->second;
890}
891
909static std::string resolve_workspace(entropic_handle_t handle,
910 const std::string& workspace,
911 std::string& err) {
912 namespace fs = std::filesystem;
913 std::string name = workspace;
914 if (fs::path(workspace).is_absolute()) {
915 std::error_code ec;
916 name = fs::weakly_canonical(workspace, ec).string();
917 bool exists = false;
918 {
919 std::lock_guard<std::mutex> guard(handle->workspace_mutex);
920 exists = handle->workspaces.count(name) > 0;
921 }
922 if (!exists && entropic_workspace_create(handle, name.c_str(),
923 name.c_str()) != ENTROPIC_OK) {
924 const char* msg = entropic_last_error(handle);
925 err = "workspace '" + workspace + "': "
926 + (msg ? msg : "create failed");
927 }
928 }
929 return err.empty() ? name : std::string{};
930}
931
948 const std::string& session_key,
949 const std::string& workspace) {
950 std::string err;
951 if (workspace.empty()) { return err; }
952 const std::string name = resolve_workspace(handle, workspace, err);
953 const std::string current = err.empty()
954 ? bound_workspace(handle, session_key) : std::string{};
955 if (err.empty() && current != name) {
956 if (entropic_session_bind_workspace(handle, session_key.c_str(),
957 name.c_str()) != ENTROPIC_OK) {
958 const char* msg = entropic_last_error(handle);
959 err = msg ? msg : "workspace bind failed";
960 }
961 }
962 return err;
963}
964
983 const json& args, const std::string& session_key,
984 int client_fd, const json& progress_token) {
985 TurnTicket ticket(bridge, session_key);
986 if (!ticket.held()) {
987 return turn_timeout_result(bridge, ticket.waited_ms());
988 }
989 return bridge->ask_streaming()
990 ? handle_ask(handle, args, session_key, client_fd, progress_token)
991 : handle_ask_plain(handle, args, session_key);
992}
993
1007 const json& args,
1008 int client_fd,
1009 const json& progress_token) {
1010 // gh#144 (v2.12.0): optional. Absent means the shared default session,
1011 // i.e. exactly what every pre-2.12.0 client already gets.
1012 const std::string session_key = args.value("session", std::string{});
1013 // gh#205 (v2.14.0): bind the session to its repository before it runs.
1014 const std::string ws_err = bind_session_workspace(
1015 handle, session_key, args.value("workspace", std::string{}));
1016 json result;
1017 if (!ws_err.empty()) {
1018 result = tool_text("error: " + ws_err);
1019 } else if (args.value("async", false)) {
1020 // The async worker takes its own ticket inside run_async_ask — it
1021 // must not hold one here, or registering the task would block behind
1022 // an in-flight turn and defeat the point of async.
1023 auto task_id = generate_task_id();
1024 bridge->run_async_ask(
1025 args.value("prompt", ""), task_id, client_fd, session_key,
1026 args.value("tier", std::string{}));
1027 result = tool_text("async task started: " + task_id);
1028 } else {
1029 result = ask_in_turn(handle, bridge, args, session_key, client_fd,
1031 }
1032 return result;
1033}
1034
1042static std::string resident_tiers(entropic_handle_t handle) {
1043 char* raw = nullptr;
1044 std::string names;
1045 if (entropic_residency_snapshot(handle, &raw) == ENTROPIC_OK && raw != nullptr) {
1046 auto snap = json::parse(raw, nullptr, false);
1047 const auto list = snap.is_object() ? snap.value("residency", json::array())
1048 : json::array();
1049 for (const auto& r : list) {
1050 names += (names.empty() ? "" : ", ") + r.value("tier", std::string("?"));
1051 }
1052 }
1054 return names;
1055}
1056
1074 const std::string before = resident_tiers(handle);
1075 const entropic_error_t rc = entropic_release_model(handle, nullptr);
1076 json result;
1078 result = tool_text("error: a turn is running on this engine; nothing "
1079 "was released — retry when it finishes");
1080 result["isError"] = true;
1081 } else if (rc != ENTROPIC_OK) {
1082 const char* why = entropic_last_error(handle);
1083 result = tool_text(std::string("error: release failed: ")
1084 + (why != nullptr ? why : "unknown"));
1085 result["isError"] = true;
1086 } else {
1087 result = tool_text(before.empty()
1088 ? "released: nothing was resident"
1089 : "released: " + before + " — sessions kept; the next ask reloads");
1090 }
1091 logger->info("[release] rc={} resident_before='{}'", static_cast<int>(rc), before);
1092 return result;
1093}
1094
1096using ToolHandler = json (*)(entropic_handle_t, ExternalBridge*, const json&);
1097
1109static const std::unordered_map<std::string, ToolHandler>& tool_handlers() {
1110 static const std::unordered_map<std::string, ToolHandler> table = {
1111 {tool_suffix::kAskStatus, [](entropic_handle_t, ExternalBridge* b, const json& a) {
1112 return b->handle_ask_status(a); }},
1113 {tool_suffix::kStatus, [](entropic_handle_t h, ExternalBridge* b, const json&) {
1114 return handle_status(h, b); }},
1115 {tool_suffix::kContextClear, [](entropic_handle_t h, ExternalBridge* b, const json& a) {
1116 return handle_clear(h, b, a.value("session", std::string{})); }},
1117 {tool_suffix::kContextCount, [](entropic_handle_t h, ExternalBridge*, const json& a) {
1118 return handle_count(h, a.value("session", std::string{})); }},
1119 {tool_suffix::kRelease, [](entropic_handle_t h, ExternalBridge*, const json&) {
1120 return handle_release(h); }},
1121 };
1122 return table;
1123}
1124
1143 const json& params,
1144 int client_fd,
1145 const std::string& call_id) {
1146 std::string name = params.value("name", std::string{});
1147 json args = params.value("arguments", json::object());
1148 // gh#145: strip the configured namespace, then match bare suffixes
1149 // against the same table tool_definitions() advertised from.
1150 const std::string suffix =
1151 facade::strip_tool_prefix(
1152 bridge->config().tool_prefix, name);
1153 if (suffix == tool_suffix::kAsk) {
1154 // v2.14.0: the client's own _meta.progressToken when it sent one
1155 // (MCP), else the request id so pre-2.14.0 clients keep streaming.
1156 json token = call_id;
1157 if (params.contains("_meta") && params["_meta"].is_object()
1158 && params["_meta"].contains("progressToken")) {
1159 token = params["_meta"]["progressToken"];
1160 }
1161 return dispatch_ask(handle, bridge, args, client_fd, token);
1162 }
1163 const auto& handlers = tool_handlers();
1164 const auto it = handlers.find(suffix);
1165 return it == handlers.end()
1166 ? tool_text("error: unknown tool '" + name + "'")
1167 : it->second(handle, bridge, args);
1168}
1169
1170// ── Turn queue (gh#144, v2.12.0) ─────────────────────────
1171
1181 const std::string& session_key) {
1182 const auto t0 = std::chrono::steady_clock::now();
1183 const std::string key = lane_for(session_key);
1184 std::unique_lock<std::mutex> lock(turn_mutex_);
1185 TurnLane& lane = lanes_[key]; // map nodes are stable across inserts
1186 const uint64_t ticket = lane.next_ticket++;
1187 const bool first = (ticket == lane.now_serving);
1188 if (!first) { ++lane.waiters; }
1189
1190 const bool ours = turn_cv_.wait_for(
1191 lock, std::chrono::milliseconds(timeout_ms),
1192 [&] { return lane.now_serving == ticket; });
1193
1194 if (!first) { --lane.waiters; }
1195 if (waited_ms_out != nullptr) {
1196 *waited_ms_out = static_cast<long>(
1197 std::chrono::duration_cast<std::chrono::milliseconds>(
1198 std::chrono::steady_clock::now() - t0).count());
1199 }
1200 if (!ours) {
1201 // Timed out. Record the ticket as abandoned so that when the line
1202 // reaches it, it is skipped rather than waited on forever. Do NOT
1203 // advance now_serving_ here: it currently names ANOTHER caller's
1204 // in-flight turn, and stepping over it would hand the engine to two
1205 // callers at once — the exact race this queue exists to prevent.
1206 lane.abandoned.insert(ticket);
1207 return false;
1208 }
1209 lane.busy = true;
1210 return true;
1211}
1212
1218void ExternalBridge::end_turn_wait(const std::string& session_key) {
1219 {
1220 std::lock_guard<std::mutex> lock(turn_mutex_);
1221 auto it = lanes_.find(lane_for(session_key));
1222 if (it != lanes_.end()) {
1223 TurnLane& lane = it->second;
1224 lane.busy = false;
1225 ++lane.now_serving;
1226 // Step over anyone who gave up while waiting, or the line stalls
1227 // on a ticket whose owner is long gone.
1228 while (lane.abandoned.erase(lane.now_serving) > 0) {
1229 ++lane.now_serving;
1230 }
1231 // An idle lane holds nothing worth keeping.
1232 if (lane.now_serving == lane.next_ticket) { lanes_.erase(it); }
1233 }
1234 }
1235 turn_cv_.notify_all();
1236}
1237
1245std::string ExternalBridge::lane_for(const std::string& session_key) const {
1246 const bool concurrent = handle_ == nullptr
1247 || handle_->config.concurrent_sessions;
1248 return concurrent ? session_key : std::string{};
1249}
1250
1258 std::lock_guard<std::mutex> lock(turn_mutex_);
1259 size_t n = 0;
1260 for (const auto& [_, lane] : lanes_) { n += lane.waiters; }
1261 return n;
1262}
1263
1271 std::lock_guard<std::mutex> lock(turn_mutex_);
1272 bool busy = false;
1273 for (const auto& [_, lane] : lanes_) { busy = busy || lane.busy; }
1274 return busy;
1275}
1276
1277
1278// ── ExternalBridge ───────────────────────────────────────
1279
1289 entropic_handle_t handle,
1291 const std::filesystem::path& project_dir)
1292 : handle_(handle), config_(config) {
1293 socket_path_ = config.socket_path.has_value()
1294 ? config.socket_path.value()
1295 : compute_socket_path(project_dir);
1296}
1297
1306
1318static void prepare_socket_dir(const std::filesystem::path& parent) {
1319 std::error_code ec;
1320 std::filesystem::create_directories(parent, ec);
1321 ::chmod(parent.c_str(), S_IRWXU); // 0700
1322}
1323
1337static bool socket_path_safe(const std::filesystem::path& path) {
1338 struct stat st{};
1339 if (::lstat(path.c_str(), &st) != 0) {
1340 return errno == ENOENT; // absent is fine
1341 }
1342 bool is_symlink = S_ISLNK(st.st_mode);
1343 bool is_socket = S_ISSOCK(st.st_mode);
1344 if (!is_socket || is_symlink) {
1345 logger->error(
1346 "Refusing to bind: {} is a {} (expected unix socket)",
1347 path.string(),
1348 is_symlink ? "symlink" : "non-socket file");
1349 }
1350 return is_socket && !is_symlink;
1351}
1352
1361static bool bind_and_listen(int fd, const std::filesystem::path& path) {
1362 auto s = path.string();
1363 if (s.size() >= sizeof(sockaddr_un::sun_path)) { return false; }
1364 struct sockaddr_un addr{};
1365 addr.sun_family = AF_UNIX;
1366 std::strncpy(addr.sun_path, s.c_str(),
1367 sizeof(addr.sun_path) - 1);
1368 if (bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
1369 return false;
1370 }
1371 ::chmod(s.c_str(), S_IRUSR | S_IWUSR); // 0600 — owner only
1372 // v2.14.0: several clients may connect at once; a backlog of 1 refused
1373 // the second while the accept loop was busy with the first.
1374 return listen(fd, SOMAXCONN) == 0;
1375}
1376
1392static int create_listen_socket(const std::filesystem::path& path) {
1393 prepare_socket_dir(path.parent_path());
1394 if (!socket_path_safe(path)) { return -1; }
1395 std::filesystem::remove(path);
1396
1397 int fd = socket(AF_UNIX, SOCK_STREAM, 0);
1398 bool ok = (fd >= 0) && bind_and_listen(fd, path);
1399 if (!ok) {
1400 logger->error("Socket setup failed for {}: {}",
1401 path.string(), std::strerror(errno));
1402 if (fd >= 0) { ::close(fd); }
1403 return -1;
1404 }
1405 // v2.14.0: the old line labelled ~/.entropic as "project_dir(canonical)".
1406 logger->info("External MCP bridge ready: socket={}", path.string());
1407 return fd;
1408}
1409
1424static bool peer_uid_matches(int client_fd) {
1425 struct ucred cred{};
1426 socklen_t len = sizeof(cred);
1428 &cred, &len) != 0) {
1429 logger->warn("SO_PEERCRED failed on fd={}: {}",
1430 client_fd, std::strerror(errno));
1431 return false;
1432 }
1433 if (cred.uid != ::geteuid()) {
1434 logger->warn(
1435 "Rejecting MCP client on fd={}: peer uid {} != engine uid {}",
1436 client_fd, cred.uid, ::geteuid());
1437 return false;
1438 }
1439 return true;
1440}
1441
1449 // v2.14.0: a key that bounds nothing must say so rather than look like
1450 // a control (cf. gh#175).
1451 if (config_.rate_limit != ExternalMCPConfig{}.rate_limit) {
1452 logger->warn("mcp.external.rate_limit={} is not enforced and is "
1453 "ignored", config_.rate_limit);
1454 }
1455 // Refuse to start if another handle in this process already owns
1456 // this socket path. The pre-fix create_listen_socket call unlinks
1457 // any existing socket file before binding — that's correct for
1458 // crashed-prior-process recovery, but if a live in-process bridge
1459 // owned that file, the unlink stole its binding silently. The
1460 // claim must happen *before* create_listen_socket so a concurrent
1461 // start() on a colliding path cannot pass the check and then race
1462 // into the unlink.
1463 std::error_code ec;
1464 auto canonical =
1465 std::filesystem::weakly_canonical(socket_path_, ec).string();
1466 if (ec) { canonical = socket_path_.string(); }
1467 {
1468 std::lock_guard lk(s_bound_sockets_mu);
1469 if (!s_bound_sockets.insert(canonical).second) {
1470 logger->warn(
1471 "External MCP bridge: socket {} already bound by another "
1472 "handle in this process; declining to start. Set "
1473 "external.socket_path to a distinct path per handle "
1474 "if you need per-handle bridges.",
1475 canonical);
1476 return false;
1477 }
1478 }
1479
1480 listen_fd_ = create_listen_socket(socket_path_);
1481 if (listen_fd_ < 0) {
1482 std::lock_guard lk(s_bound_sockets_mu);
1483 s_bound_sockets.erase(canonical);
1484 return false;
1485 }
1486 bound_canonical_ = canonical;
1487
1488 running_.store(true);
1489 // gh#59 (v2.3.1): the accept thread (and the per-client serve
1490 // threads it spawns) need to log on this handle's behalf — install
1491 // a HandleLogScope at the top of each thread body so spdlog lines
1492 // route through HandleAwareSink to this handle's session.log.
1493 int log_id = handle_ ? handle_->log_id : 0;
1494 accept_thread_ = std::thread([this, log_id]() {
1496 accept_loop();
1497 });
1498
1499 logger->info("External MCP bridge listening on {}",
1500 socket_path_.string());
1501 return true;
1502}
1503
1525 running_.store(false);
1526 // v2.14.0: the accept thread READS listen_fd_ every poll, so it must be
1527 // joined before listen_fd_ is written. shutdown() wakes a blocked
1528 // poll/accept without touching the variable; the loop then sees
1529 // running_ == false (it re-checks at least every 500 ms regardless).
1530 // Closing first was a data race TSan reported the first time a test
1531 // destroyed a handle with the bridge enabled.
1532 if (listen_fd_ >= 0) { ::shutdown(listen_fd_, SHUT_RDWR); }
1533 if (accept_thread_.joinable()) {
1534 accept_thread_.join();
1535 }
1536 if (listen_fd_ >= 0) {
1537 ::close(listen_fd_);
1538 listen_fd_ = -1;
1539 }
1540
1541 // Wake every connected client's blocking read so the per-client
1542 // thread can observe the disconnect and exit. shutdown(SHUT_RDWR)
1543 // on a connected socket returns EOF to the peer; the read in
1544 // serve_client returns 0 and read_line returns "" → exits.
1545 std::vector<std::unique_ptr<ClientThread>> drained;
1546 {
1547 std::lock_guard<std::mutex> lock(client_threads_mutex_);
1548 drained = std::move(client_threads_);
1549 client_threads_.clear();
1550 }
1551 for (auto& ct : drained) {
1552 // v2.14.2: under the client's fd_mutex — its thread may be closing
1553 // the same descriptor right now (TSan).
1554 std::lock_guard<std::mutex> fd_lock(ct->fd_mutex);
1555 if (ct->fd >= 0) { ::shutdown(ct->fd, SHUT_RDWR); }
1556 }
1557 for (auto& ct : drained) {
1558 if (ct->thread.joinable()) { ct->thread.join(); }
1559 }
1560 join_async_workers(); // v2.14.2: no worker may outlive the bridge
1561
1562 // Clean up socket file
1563 std::error_code ec;
1564 std::filesystem::remove(socket_path_, ec);
1565
1566 // Release our claim on the canonical path so a later handle in
1567 // this process can re-bind. Use the exact string we inserted in
1568 // start(); recomputing weakly_canonical here could differ if the
1569 // path was deleted mid-run.
1570 if (!bound_canonical_.empty()) {
1571 std::lock_guard lk(s_bound_sockets_mu);
1572 s_bound_sockets.erase(bound_canonical_);
1573 bound_canonical_.clear();
1574 }
1575}
1576
1589void ExternalBridge::reap_finished_clients_locked() {
1590 auto it = client_threads_.begin();
1591 while (it != client_threads_.end()) {
1592 if ((*it)->finished.load()) {
1593 if ((*it)->thread.joinable()) { (*it)->thread.join(); }
1594 it = client_threads_.erase(it);
1595 } else {
1596 ++it;
1597 }
1598 }
1599}
1600
1626void ExternalBridge::accept_loop() {
1627 while (running_.load()) {
1628 struct pollfd pfd{};
1629 pfd.fd = listen_fd_;
1630 pfd.events = POLLIN;
1631
1632 int rc = poll(&pfd, 1, 500); // 500ms timeout for shutdown check
1633 if (rc <= 0) { continue; }
1634
1635 int client_fd = accept(listen_fd_, nullptr, nullptr);
1636 if (client_fd < 0) { continue; }
1637
1639 ::close(client_fd); // v2.1.7 (gh#34): cross-uid attempt
1640 continue;
1641 }
1642
1643 logger->info("External MCP client connected (fd={})", client_fd);
1644
1645 auto ct = std::make_unique<ClientThread>();
1646 ct->fd = client_fd;
1647 // Capture a raw pointer for the thread body so the unique_ptr
1648 // can stay in client_threads_ without aliasing.
1649 ClientThread* raw = ct.get();
1650 // gh#59 (v2.3.1): propagate the handle log scope into the
1651 // per-client thread so its `serve_client` logs route to the
1652 // owning handle's session.log.
1653 int log_id = handle_ ? handle_->log_id : 0;
1654 ct->thread = std::thread([this, raw, log_id]() {
1656 serve_client(raw->fd);
1657 {
1658 std::lock_guard<std::mutex> fd_lock(raw->fd_mutex);
1659 ::close(raw->fd);
1660 raw->fd = -1;
1661 }
1662 logger->info("External MCP client disconnected");
1663 raw->finished.store(true);
1664 });
1665
1666 std::lock_guard<std::mutex> lock(client_threads_mutex_);
1667 client_threads_.push_back(std::move(ct));
1668 reap_finished_clients_locked();
1669 }
1670}
1671
1679static std::string read_line(int fd) {
1680 std::string line;
1681 char c;
1682 while (true) {
1683 ssize_t n = read(fd, &c, 1);
1684 if (n <= 0) { return {}; }
1685 if (c == '\n') { return line; }
1686 line += c;
1687 }
1688}
1689
1700void ExternalBridge::serve_client(int client_fd) {
1702 struct Unsub {
1703 ExternalBridge* self;
1704 int fd;
1705 ~Unsub() { self->unsubscribe(fd); }
1706 } guard{this, client_fd};
1707
1708 while (running_.load()) {
1709 auto line = read_line(client_fd);
1710 if (line.empty()) { break; }
1711
1713 if (response.empty()) { continue; } // notification — no reply
1714 response += '\n';
1715
1717 response.size());
1718 if (written < 0) { break; }
1719 }
1720}
1721
1738 const std::string name =
1739 cfg.server_name.empty() ? "entropic" : cfg.server_name;
1740 return {
1741 {"protocolVersion", "2025-06-18"},
1742 {"serverInfo", {{"name", name},
1743 {"version", entropic_version()}}},
1744 {"capabilities", {{"tools", json::object()}}}
1745 };
1746}
1747
1762 const std::string& request, int client_fd) {
1763 auto req = json::parse(request, nullptr, false);
1764 // Parse error or notification (no id) → no dispatch
1765 if (req.is_discarded() || !req.contains("id")) {
1766 return req.is_discarded()
1767 ? rpc_err(nullptr, -32700, "Parse error")
1768 : std::string{};
1769 }
1770
1771 json id = req["id"];
1772 std::string method = req.value("method", std::string{});
1773 json params = req.value("params", json::object());
1774 json result;
1775
1776 if (method == "initialize") { result = initialize_result(config_); }
1777 else if (method == "tools/list") { result = {{"tools", tool_definitions(config_)}}; }
1778 else if (method == "tools/call") {
1779 auto id_str = id.is_string() ? id.get<std::string>()
1780 : id.dump();
1781 result = dispatch_tool(handle_, this, params, client_fd, id_str);
1782 }
1783 else if (method == "shutdown" || method == "exit") { result = json::object(); }
1784 else { return rpc_err(id, -32601, "Unknown method: " + method); }
1785 return rpc_ok(id, result);
1786}
1787
1788// ── Async task support ───────────────────────────────────
1789
1795static void phase_observer_cb(int state, void* ud) {
1796 auto* self = static_cast<ExternalBridge*>(ud);
1797 if (state != ENTROPIC_AGENT_STATE_VERIFYING) { return; }
1798 std::lock_guard<std::mutex> lock(self->tasks_mutex_);
1799 // E5+E6 (2.1.0): discard stale callbacks fired after detach_phase_observer
1800 // incremented observer_gen_. attached_gen_ was captured at attach time.
1801 if (self->observer_call_is_stale()) { return; }
1802 auto it = self->tasks_for_cancel().find(self->active_task_id_for_observer());
1803 if (it == self->tasks_for_cancel().end()) { return; }
1804 // First VERIFYING = "validating"; subsequent VERIFYING transitions
1805 // on the same task indicate revision retries → "revising".
1806 it->second.phase = (it->second.phase == "validating"
1807 || it->second.phase == "revising")
1808 ? "revising" : "validating";
1809}
1810
1822void ExternalBridge::attach_phase_observer(const std::string& task_id) {
1823 {
1824 std::lock_guard<std::mutex> lock(tasks_mutex_);
1825 active_task_id_ = task_id;
1826 attached_gen_ = ++observer_gen_;
1827 }
1829}
1830
1844 {
1845 std::lock_guard<std::mutex> lock(tasks_mutex_);
1846 ++observer_gen_;
1847 active_task_id_.clear();
1848 }
1849 entropic_set_state_observer(handle_, nullptr, nullptr);
1850}
1851
1870 const std::string& task_id, const std::string& status,
1871 const std::string& phase, const std::string& text) {
1872 std::lock_guard<std::mutex> lock(tasks_mutex_);
1873 auto it = tasks_.find(task_id);
1874 if (it != tasks_.end()) {
1875 it->second.status = status;
1876 it->second.phase = phase;
1877 it->second.result = text;
1878 }
1879 write_sentinel(task_id, status);
1880}
1881
1895void ExternalBridge::fail_task_engine_busy(const std::string& task_id,
1896 long waited_ms) {
1897 update_task_phase(task_id, "error", "engine busy");
1898 std::lock_guard<std::mutex> lock(tasks_mutex_);
1899 auto it = tasks_.find(task_id);
1900 if (it != tasks_.end()) {
1901 it->second.status = "error";
1902 it->second.result = "engine busy — waited "
1903 + std::to_string(waited_ms) + " ms for a turn";
1904 write_sentinel(task_id, "error");
1905 }
1906}
1907
1925static json task_complete_notification(const std::string& task_id,
1926 const std::string& status) {
1927 return {
1928 {"jsonrpc", "2.0"},
1929 {"method", "notifications/progress"},
1930 {"params", {
1931 {"progressToken", task_id},
1932 {"progress", 100},
1933 {"total", 100},
1934 {"message", status}
1935 }}
1936 };
1937}
1938
1953 const std::string& prompt,
1954 const std::string& task_id,
1955 int client_fd,
1956 const std::string& session_key,
1957 const std::string& tier) {
1958 {
1959 std::lock_guard<std::mutex> lock(tasks_mutex_);
1960 AsyncTask t;
1961 t.status = "queued";
1962 t.phase = "queued";
1963 t.created = std::chrono::steady_clock::now();
1964 t.session_key = session_key; // v2.14.0: clear cancels per session
1965 t.owner_fd = client_fd; // v2.14.0: only the owner is notified
1966 tasks_[task_id] = std::move(t);
1967 }
1968
1969 // gh#59 (v2.3.1): the async-ask worker runs entropic_run on this
1970 // handle's behalf — scope its logs to the owning handle.
1971 int log_id = handle_ ? handle_->log_id : 0;
1972 // v2.14.2: owned and joined by stop(), no longer detached — a detached
1973 // worker outliving the bridge touched freed memory (TSan).
1974 auto worker = std::make_unique<AsyncWorker>();
1975 AsyncWorker* raw = worker.get();
1976 std::lock_guard<std::mutex> lock(async_workers_mutex_);
1977 reap_finished_async_locked();
1978 worker->thread = std::thread([this, raw, prompt, task_id, client_fd,
1979 log_id, session_key, tier]() {
1981 async_worker(prompt, task_id, client_fd, session_key, tier);
1982 raw->finished.store(true);
1983 });
1984 async_workers_.push_back(std::move(worker));
1985}
1986
1992void ExternalBridge::reap_finished_async_locked() {
1993 for (auto it = async_workers_.begin(); it != async_workers_.end();) {
1994 const bool done = (*it)->finished.load();
1995 if (done && (*it)->thread.joinable()) { (*it)->thread.join(); }
1996 it = done ? async_workers_.erase(it) : std::next(it);
1997 }
1998}
1999
2010void ExternalBridge::join_async_workers() {
2011 std::vector<std::unique_ptr<AsyncWorker>> workers;
2012 {
2013 std::lock_guard<std::mutex> lock(async_workers_mutex_);
2014 workers = std::move(async_workers_);
2015 async_workers_.clear();
2016 }
2017 const bool running = std::any_of(workers.begin(), workers.end(),
2018 [](const auto& w) { return !w->finished.load(); });
2019 if (running && handle_ != nullptr && handle_->engine) {
2020 logger->info("Bridge stopping with {} async ask(s) in flight — "
2021 "interrupting the engine", workers.size());
2022 handle_->engine->interrupt();
2023 }
2024 for (auto& w : workers) {
2025 if (w->thread.joinable()) { w->thread.join(); }
2026 }
2027}
2028
2046void ExternalBridge::async_worker(const std::string& prompt,
2047 const std::string& task_id, int client_fd,
2048 const std::string& session_key,
2049 const std::string& tier) {
2050 long waited_ms = 0;
2051 if (!begin_turn_wait(kTurnWaitTimeoutMs, &waited_ms, session_key)) {
2053 return;
2054 }
2055 update_task_phase(task_id, "running", "running");
2056 attach_phase_observer(task_id);
2057 char* result_json = nullptr;
2058 auto err = run_plain_turn(handle_, session_key, tier, prompt,
2059 &result_json);
2061 end_turn_wait(session_key);
2062
2064 commit_async_final_state(task_id, final_state.status,
2065 final_state.phase, final_state.text);
2067 task_complete_notification(task_id, final_state.status));
2069 logger->info("Async task {} completed: {}", task_id, final_state.status);
2070}
2071
2084 std::lock_guard<std::mutex> lock(subscribers_mutex_);
2085 subscribers_.insert(fd);
2086}
2087
2095 std::lock_guard<std::mutex> lock(subscribers_mutex_);
2096 subscribers_.erase(fd);
2097}
2098
2112static bool send_nonblocking(int fd, const std::string& payload) {
2113 ssize_t rc = ::send(fd, payload.c_str(), payload.size(),
2115 if (rc < 0) {
2116 logger->warn("Subscriber fd {} send failed (errno={}) — dropping",
2117 fd, errno);
2118 } else if (static_cast<size_t>(rc) < payload.size()) {
2119 logger->warn("Subscriber fd {} partial send ({}/{}) — dropping",
2120 fd, rc, payload.size());
2121 }
2122 return rc >= 0 && static_cast<size_t>(rc) == payload.size();
2123}
2124
2141void ExternalBridge::notify_client(int fd, const json& notif) {
2142 std::lock_guard<std::mutex> lock(subscribers_mutex_);
2143 if (fd >= 0 && subscribers_.count(fd) > 0
2144 && !send_nonblocking(fd, notif.dump() + "\n")) {
2145 subscribers_.erase(fd);
2146 }
2147}
2148
2172 const auto payload = notif.dump() + "\n";
2173 std::lock_guard<std::mutex> lock(subscribers_mutex_);
2174 for (auto it = subscribers_.begin(); it != subscribers_.end();) {
2175 it = send_nonblocking(*it, payload) ? std::next(it) : subscribers_.erase(it);
2176 }
2177}
2178
2187void ExternalBridge::update_task_phase(const std::string& task_id,
2188 const std::string& status,
2189 const std::string& phase) {
2190 std::lock_guard<std::mutex> lock(tasks_mutex_);
2191 auto it = tasks_.find(task_id);
2192 if (it == tasks_.end()) { return; }
2193 it->second.status = status;
2194 it->second.phase = phase;
2195}
2196
2210 auto cutoff = std::chrono::steady_clock::now()
2211 - std::chrono::minutes(15);
2213 std::lock_guard<std::mutex> lock(tasks_mutex_);
2214 for (auto it = tasks_.begin(); it != tasks_.end(); ) {
2215 if (it->second.created < cutoff) {
2216 if (!sentinel_dir.empty()) {
2217 for (const char* suffix :
2218 {".done", ".failed", ".cancelled"}) {
2219 std::error_code ec;
2220 std::filesystem::remove(
2221 sentinel_dir / (it->first + suffix), ec);
2222 }
2223 }
2224 it = tasks_.erase(it);
2225 } else {
2226 ++it;
2227 }
2228 }
2229}
2230
2243std::filesystem::path ExternalBridge::async_sentinel_dir() const {
2244 std::filesystem::path root = async_sentinel_root_override_;
2245 if (root.empty() && handle_ != nullptr) {
2246 root = handle_->config.log_dir;
2247 }
2248 return root.empty() ? std::filesystem::path{} : (root / "async");
2249}
2250
2257 const std::filesystem::path& root) {
2258 async_sentinel_root_override_ = root;
2259}
2260
2267 const std::string& status) {
2268 const char* suffix = ".done";
2269 if (status == "error") {
2270 suffix = ".failed";
2271 } else if (status == "cancelled") {
2272 suffix = ".cancelled";
2273 }
2274 return suffix;
2275}
2276
2292void ExternalBridge::write_sentinel(const std::string& task_id,
2293 const std::string& status) {
2294 auto dir = async_sentinel_dir();
2295 if (dir.empty()) { return; }
2296 std::error_code ec;
2297 std::filesystem::create_directories(dir, ec);
2298 if (ec) {
2299 logger->warn("write_sentinel: mkdir {} failed: {}",
2300 dir.string(), ec.message());
2301 return;
2302 }
2303 auto path = dir / (task_id + sentinel_suffix_for_status(status));
2304 std::ofstream out(path);
2305 if (!out.is_open()) {
2306 logger->warn("write_sentinel: open {} failed", path.string());
2307 return;
2308 }
2309 out << status << '\n';
2310}
2311
2312} // 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.
bool begin_turn_wait(long timeout_ms, long *waited_ms_out, const std::string &session_key="")
Take a ticket and wait for this caller's turn on the engine.
void unsubscribe(int fd)
Remove an fd from the subscriber set.
std::mutex tasks_mutex_
Async task mutex (public for dispatch_tool access).
const ExternalMCPConfig & config() const
The external MCP config this bridge was constructed with.
~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.
void run_async_ask(const std::string &prompt, const std::string &task_id, int client_fd, const std::string &session_key="", const std::string &tier="")
Run an ask on a background worker and notify its owner.
void notify_client(int fd, const nlohmann::json &notif)
Write a notification to ONE subscribed client (v2.14.0).
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 end_turn_wait(const std::string &session_key="")
Hand the turn to the next ticket in this session's lane.
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 fail_task_engine_busy(const std::string &task_id, long waited_ms)
Mark an async task failed because its turn never came (gh#144).
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.
void commit_async_final_state(const std::string &task_id, const std::string &status, const std::string &phase, const std::string &text)
Record an async task's terminal state and write its sentinel.
std::string dispatch(const std::string &request, int client_fd)
Dispatch one JSON-RPC request and return the response.
bool turn_in_flight() const
Whether a turn is currently running on the engine.
size_t turn_queue_depth() const
How many callers are waiting behind the one(s) in flight.
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_run_session_as(entropic_handle_t handle, const char *session_key, const char *tier_or_identity, const char *input, char **result_json)
Run a turn on a named session under a named tier (gh#144).
ENTROPIC_EXPORT entropic_error_t entropic_workspace_create(entropic_handle_t handle, const char *name, const char *dir)
Create a named workspace rooted at a directory (gh#166).
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_run_session_streaming_as(entropic_handle_t handle, const char *session_key, const char *tier_or_identity, const char *input, void(*on_token)(const char *token, size_t len, void *user_data), void *user_data, int *cancel_flag)
Streaming turn on a named session, under a named tier (gh#205).
ENTROPIC_EXPORT entropic_error_t entropic_residency_snapshot(entropic_handle_t handle, char **out_json)
Snapshot of the engine's current VRAM residency set.
ENTROPIC_EXPORT entropic_error_t entropic_interrupt_session(entropic_handle_t handle, const char *session_key)
Interrupt the run belonging to ONE session (gh#158, v2.13.0).
ENTROPIC_EXPORT entropic_error_t entropic_session_list(entropic_handle_t handle, char **sessions_json)
List the sessions this handle currently holds (gh#144).
ENTROPIC_EXPORT entropic_error_t entropic_run_session_streaming(entropic_handle_t handle, const char *session_key, const char *input, void(*on_token)(const char *token, size_t len, void *user_data), void *user_data, int *cancel_flag)
Streaming turn on a named session (gh#144).
ENTROPIC_EXPORT entropic_error_t entropic_session_bind_workspace(entropic_handle_t handle, const char *session_key, const char *name)
Bind a session to a workspace (gh#166).
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 entropic_error_t entropic_session_context_get(entropic_handle_t handle, const char *session_key, char **messages_json)
Read one session's conversation as a JSON array (gh#144).
ENTROPIC_EXPORT const char * entropic_version(void)
Get the library version string.
ENTROPIC_EXPORT entropic_error_t entropic_session_context_clear(entropic_handle_t handle, const char *session_key)
Clear one session's history, leaving other sessions intact (gh#144).
ENTROPIC_EXPORT void entropic_free(void *ptr)
Free memory allocated by the engine or entropic_alloc().
ENTROPIC_EXPORT entropic_error_t entropic_run_session(entropic_handle_t handle, const char *session_key, const char *input, char **result_json)
Run a turn on a named session (gh#144).
ENTROPIC_EXPORT entropic_error_t entropic_session_context_count(entropic_handle_t handle, const char *session_key, size_t *count)
Message count for one session (gh#144).
ENTROPIC_EXPORT entropic_error_t entropic_release_model(entropic_handle_t handle, const char *tier_name)
Release a resident model, keeping everything else on the handle.
@ 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_ALREADY_RUNNING
entropic_run already in progress on handle (v1.8.9)
Definition error.h:61
@ 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:156
Unix socket MCP bridge — exposes a running engine to external clients.
std::string err
size_exceeded, when set
const ReadWindow & w
Requested window.
Operator-visible final-text extraction for the external bridge.
std::thread worker
Runs dispatch()
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 std::string bound_workspace(entropic_handle_t handle, const std::string &session_key)
The session's current workspace binding, or "" (gh#205).
static json handle_ask(entropic_handle_t handle, const json &args, const std::string &session_key, int client_fd, const json &progress_token)
Handle entropic.ask — stream tokens then return final text.
static std::string resident_tiers(entropic_handle_t handle)
Names of the models resident right now, from the snapshot.
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.
static json tool_definitions(const ExternalMCPConfig &cfg)
MCP tool definitions exposed by the bridge.
static json dispatch_ask(entropic_handle_t handle, ExternalBridge *bridge, const json &args, int client_fd, const json &progress_token)
Route entropic.ask — sync (streaming) or async.
static std::string resolve_workspace(entropic_handle_t handle, const std::string &workspace, std::string &err)
Resolve workspace to a workspace name, creating one for a path.
@ ok
Tool dispatched, returned non-empty content.
static json ask_tool(const ExternalMCPConfig &cfg)
Definition for the ask tool (prompt + optional async).
@ optional
optional: every escape asks the host's path approver
static void phase_observer_cb(int state, void *ud)
State observer that projects VERIFYING onto task phase.
static void send_progress(int fd, const std::string &token_text, const json &progress_token, uint64_t seq)
Send an MCP progress notification with a text token.
static std::string bind_session_workspace(entropic_handle_t handle, const std::string &session_key, const std::string &workspace)
Bind a session to the requested workspace, idempotently (gh#205).
static bool socket_path_safe(const std::filesystem::path &path)
Reject a pre-existing path that is a symlink or non-socket.
static json session_scoped_tool(const ExternalMCPConfig &cfg, const char *suffix, const char *fallback)
Definition for a tool scoped to an optional session (gh#144).
static bool send_nonblocking(int fd, const std::string &payload)
One non-blocking write of a whole payload to one fd.
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 json handle_status(entropic_handle_t handle, const ExternalBridge *bridge)
Handle the status tool.
@ request
GenerationParams::grammar (COMMON_GRAMMAR_TYPE_USER)
@ tier
The TIER's identity frontmatter grammar: stem (gh#154).
@ count
Sentinel — MUST remain last.
static json initialize_result(const ExternalMCPConfig &cfg)
Build the MCP initialize response payload.
static json handle_clear(entropic_handle_t handle, ExternalBridge *bridge, const std::string &session_key)
entropic.context_clear MCP tool handler.
static json ask_status_tool(const ExternalMCPConfig &cfg)
Definition for the ask_status tool (task_id lookup).
static const std::unordered_map< std::string, ToolHandler > & tool_handlers()
Non-ask tools by suffix (v2.14.3).
static json task_complete_notification(const std::string &task_id, const std::string &status)
The completion notification for an async task (Issue #4, v2.1.2).
static void cancel_inflight_async_tasks(entropic_handle_t handle, ExternalBridge *bridge, const std::string &session_key)
Cancel any async tasks currently running on the bridge.
static json handle_release(entropic_handle_t handle)
release: unload every resident model, keep everything else.
static bool bind_and_listen(int fd, const std::filesystem::path &path)
Bind+listen on an AF_UNIX socket, applying 0600 perms.
static json ask_in_turn(entropic_handle_t handle, ExternalBridge *bridge, const json &args, const std::string &session_key, int client_fd, const json &progress_token)
Run a sync ask once this session's turn comes up (gh#144, gh#205).
@ read
read_file, list_directory
@ write
write_file, edit_file
static entropic_error_t run_plain_turn(entropic_handle_t handle, const std::string &session_key, const std::string &tier, const std::string &prompt, char **result_json)
Run one non-streaming turn, under tier when one is given (gh#205).
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 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, const std::string &session_key)
Handle entropic.ask — non-streaming path (gh#115, v2.9.12).
static json no_args_tool(const ExternalMCPConfig &cfg, const char *suffix, const char *fallback)
Definition for a tool that takes no arguments.
static entropic_error_t run_stream_turn(entropic_handle_t handle, const std::string &session_key, const std::string &tier, const std::string &prompt, void(*on_token)(const char *, size_t, void *), void *ud)
Run one streaming turn, under tier when one is given (gh#205).
static std::string rpc_err(const json &id, int code, const std::string &msg)
Build a JSON-RPC error response.
static json final_answer_from_context(entropic_handle_t handle, const std::string &session_key)
Read the live conversation and render the operator-visible answer.
static json handle_count(entropic_handle_t handle, const std::string &session_key)
Handle entropic.context_count.
static std::string describe(const ExternalMCPConfig &cfg, const char *suffix, const char *fallback)
Resolve a tool's description, honouring any consumer override.
static bool mark_tasks_cancelling(ExternalBridge *bridge, const std::string &session_key)
Mark one session's queued/running tasks as cancelling.
static bool any_cancelling_left(ExternalBridge *bridge, const std::string &session_key)
True while one session's task is still phase=cancelling.
json(*)(entropic_handle_t, ExternalBridge *, const json &) ToolHandler
A non-ask tool's handler (v2.14.3).
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 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.
std::string config
–config FILE
Definition serve.cpp:38
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 status
queued | running | done | error | cancelled (2.0.6-rc16)
External MCP server configuration (Entropic-as-server).
Definition config.h:856
std::optional< std::filesystem::path > socket_path
Socket path (nullopt = derived)
Definition config.h:858
int rate_limit
NOT ENFORCED.
Definition config.h:863
bool concurrent_sessions
gh#158 (v2.13.0): may DIFFERENT session keys run together?
Definition config.h:1406
std::filesystem::path log_dir
Session log directory (session.log + session_model.log).
Definition config.h:1416
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.
std::mutex workspace_mutex
gh#166: guards both maps above. Held for lookups only.
std::map< std::string, std::unique_ptr< EntropicWorkspace > > workspaces
gh#166: named workspaces, by name.
std::map< std::string, std::string > session_workspace
gh#166: session key → workspace name.
std::unique_ptr< entropic::AgentEngine > engine
Agentic loop (owns conversation state)
entropic::ParsedConfig config
Parsed config.
gh#145: the bridge's tool namespace, as a pure pair of functions.