Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
tool_executor.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
13
14#include <nlohmann/json.hpp>
15
16#include <algorithm>
17#include <chrono>
18#include <memory>
19#include <optional>
20
21static auto logger = entropic::log::get("mcp.tool_executor");
22
23namespace entropic {
24
35 ServerManager& server_manager,
36 const LoopConfig& loop_config,
37 EngineCallbacks& callbacks,
39 : server_manager_(server_manager),
40 loop_config_(loop_config),
41 callbacks_(callbacks),
42 hooks_(hooks) {}
43
52 ServerManager* (*fn)(const std::string&, void*), void* user_data) {
53 server_resolver_ = fn;
54 server_resolver_data_ = user_data;
55}
56
64ServerManager& ToolExecutor::servers_for(
65 const std::string& session_key) const {
66 if (server_resolver_ != nullptr) {
67 auto* resolved = server_resolver_(session_key, server_resolver_data_);
68 if (resolved != nullptr) { return *resolved; }
69 }
70 return server_manager_;
71}
72
80 const PermissionPersistInterface& persist) {
81 permission_persist_ = persist;
82}
83
102 const std::vector<ToolCall>& tool_calls) {
103 logger->info("Processing {} tool calls", tool_calls.size());
104 ctx.state = AgentState::WAITING_TOOL;
105 fire_state_callback(ctx);
106
107 auto limited = sort_tool_calls(tool_calls);
108 int eff_limit = ctx.effective_max_tool_calls_per_turn >= 0
109 ? ctx.effective_max_tool_calls_per_turn
110 : loop_config_.max_tool_calls_per_turn;
111 truncate_to_limit(limited, eff_limit);
112
113 std::vector<Message> results;
114 for (const auto& call : limited) {
115 auto msgs = process_single_call(ctx, call);
116 for (auto& m : msgs) {
117 results.push_back(std::move(m));
118 }
119 if (should_stop_batch(ctx, results)) {
120 break;
121 }
122 }
123
124 ctx.consecutive_errors = 0;
125 return results;
126}
127
140std::vector<ToolCall> ToolExecutor::sort_tool_calls(
141 const std::vector<ToolCall>& calls) {
142 auto sorted = calls;
143 std::stable_sort(sorted.begin(), sorted.end(),
144 [](const ToolCall& a, const ToolCall& b) {
145 bool a_delegate = (a.name == "entropic.delegate");
146 bool b_delegate = (b.name == "entropic.delegate");
147 return !a_delegate && b_delegate;
148 });
149 return sorted;
150}
151
167std::string ToolExecutor::check_duplicate(
168 const LoopContext& ctx,
169 const ToolCall& call) const {
170 if (server_manager_.skip_duplicate_check(call.name)) {
171 return "";
172 }
173 auto key = tool_call_key(call);
174 auto it = ctx.recent_tool_calls.find(key);
175 if (it != ctx.recent_tool_calls.end()) {
176 return it->second;
177 }
178 return "";
179}
180
197Message ToolExecutor::handle_duplicate(
198 LoopContext& ctx,
199 const ToolCall& call,
200 const std::string& previous_result) {
201 ctx.consecutive_duplicate_attempts++;
202 logger->warn("Duplicate tool call #{}: {}",
203 ctx.consecutive_duplicate_attempts, call.name);
204
205 if (ctx.consecutive_duplicate_attempts >= 3) {
206 return create_circuit_breaker_message();
207 }
208 return create_duplicate_message(call, previous_result);
209}
210
227bool ToolExecutor::check_approval(const ToolCall& call) {
228 auto args_json = serialize_args(call);
229 bool auto_ok = loop_config_.auto_approve_tools
230 || server_manager_.is_explicitly_allowed(
231 call.name, args_json);
232
233 bool approved = auto_ok;
234 if (!approved && callbacks_.on_tool_call != nullptr) {
235 auto call_json = serialize_tool_call(call);
236 callbacks_.on_tool_call(call_json.c_str(),
237 callbacks_.user_data);
238 approved = true;
239 }
240
241 // Hook: ON_PERMISSION_CHECK — informational (v1.9.1)
242 if (hook_iface_.fire_info != nullptr) {
243 std::string perm = approved ? "allowed" : "denied";
244 std::string json = "{\"tool_name\":\""
245 + call.name + "\",\"permission\":\"" + perm + "\"}";
246 hook_iface_.fire_info(hook_iface_.registry,
248 }
249
250 if (!approved) {
251 logger->warn("No approval callback — denying: {}", call.name);
252 }
253 return approved;
254}
255
268std::optional<Message> ToolExecutor::check_tier_allowed(
269 const LoopContext& ctx, const ToolCall& call) const {
270 // gh#83 (v2.5.2): enforce the locked tier's allowed_tools at
271 // dispatch — not just at prompt-injection time. Without this a
272 // model that emits an off-allowlist call (hallucinated, learned,
273 // or cross-tier) has it dispatched normally; tier isolation was
274 // advisory only. Pass through when: no tier locked, no map wired,
275 // or the tier declares no allowlist (unrestricted).
276 if (ctx.locked_tier.empty() || tier_allowed_tools_ == nullptr) {
277 return std::nullopt;
278 }
279 auto it = tier_allowed_tools_->find(ctx.locked_tier);
280 // Authorized when: tier has no allowlist entry, an empty allowlist
281 // (unrestricted), or the call is in the list. Short-circuit eval
282 // guards the `it->second` access against the end() case.
283 bool authorized = it == tier_allowed_tools_->end()
284 || it->second.empty()
285 || std::find(it->second.begin(), it->second.end(),
286 call.name) != it->second.end();
287 if (authorized) {
288 return std::nullopt;
289 }
290 logger->warn("Tool '{}' not in tier '{}' allowed_tools — rejecting",
291 call.name, ctx.locked_tier);
292 return create_denied_message(
293 call, "tier '" + ctx.locked_tier + "' is not authorized to call '"
294 + call.name + "'");
295}
296
307static std::string check_required_fields(
308 const nlohmann::json& schema,
309 const nlohmann::json& args)
310{
311 for (const auto& req : schema.value("required",
312 nlohmann::json::array())) {
313 if (!args.contains(req.get<std::string>())) {
314 return "Missing required argument: "
315 + req.get<std::string>();
316 }
317 }
318 return "";
319}
320
341static std::string check_enum(
342 const std::string& key,
343 const nlohmann::json& allowed,
344 const nlohmann::json& val)
345{
346 for (const auto& e : allowed) {
347 if (e == val) { return ""; }
348 }
349 return "Invalid value for '" + key + "': "
350 + val.dump() + ". Must be one of: " + allowed.dump();
351}
352
364static std::string check_type(
365 const std::string& key,
366 const std::string& type,
367 const nlohmann::json& val)
368{
369 bool ok = (type == "string" && val.is_string())
370 || (type == "integer" && val.is_number_integer())
371 || (type == "number" && val.is_number())
372 || (type == "boolean" && val.is_boolean())
373 || (type == "array" && val.is_array())
374 || (type == "object" && val.is_object());
375 return ok ? "" : "Type mismatch for '" + key
376 + "': expected " + type;
377}
378
394static std::string check_range(
395 const std::string& key,
396 const nlohmann::json& prop,
397 const nlohmann::json& val)
398{
399 std::string err;
400 if (val.is_number() && prop.contains("minimum")
401 && val.get<double>() < prop["minimum"].get<double>()) {
402 err = "Value for '" + key + "' is " + val.dump()
403 + ", below the minimum " + prop["minimum"].dump();
404 } else if (val.is_number() && prop.contains("maximum")
405 && val.get<double>() > prop["maximum"].get<double>()) {
406 err = "Value for '" + key + "' is " + val.dump()
407 + ", above the maximum " + prop["maximum"].dump();
408 }
409 return err;
410}
411
424static std::string check_property_constraints(
425 const std::string& key,
426 const nlohmann::json& prop,
427 const nlohmann::json& val)
428{
429 std::string err;
430 if (prop.contains("enum")) {
431 err = check_enum(key, prop["enum"], val);
432 }
433 if (err.empty() && prop.contains("type")) {
434 err = check_type(key, prop["type"].get<std::string>(), val);
435 }
436 return err.empty() ? check_range(key, prop, val) : err;
437}
438
455static std::string validate_tool_args(
456 const std::string& schema_json,
457 const nlohmann::json& args)
458{
459 auto schema = nlohmann::json::parse(schema_json, nullptr, false);
460 if (!schema.is_object()) { return ""; }
461
462 auto err = check_required_fields(schema, args);
463 auto props = schema.value("properties", nlohmann::json::object());
464 for (auto it = props.begin(); it != props.end() && err.empty(); ++it) {
465 if (args.contains(it.key())) {
467 it.key(), it.value(), args[it.key()]);
468 }
469 }
470 return err;
471}
472
482static std::string parse_tool_result_text(const std::string& result_json) {
483 try {
484 auto j = nlohmann::json::parse(result_json);
485 return j.value("result", result_json);
486 } catch (...) {
487 return result_json;
488 }
489}
490
517std::pair<Message, std::string> ToolExecutor::execute_tool(
518 LoopContext& ctx, const ToolCall& call) {
519
520 auto args_json = serialize_args(call);
521
522 if (callbacks_.on_tool_start != nullptr) {
523 auto call_json = serialize_tool_call(call);
524 callbacks_.on_tool_start(call_json.c_str(),
525 callbacks_.user_data);
526 }
527
528 auto start = std::chrono::steady_clock::now();
529 // Inbound boundary from MCP server subprocess. v2.1.0 (#47) introduced
530 // this; v2.1.1 (#3) generalized it as one of several boundary-policy
531 // sanitize sites — see include/entropic/mcp/utf8_sanitize.h for the
532 // full policy. The earlier "trust downstream" assumption was wrong:
533 // bytes also enter via the model token stream and the audit-replay
534 // path; both now sanitize at their own boundaries.
535 // gh#166 (v2.13.0): route to the RUNNING SESSION's workspace. This is
536 // the call that touches the filesystem, so it is the one that must
537 // land in the right repository; the metadata lookups above answer
538 // schema/permission questions that are workspace-invariant for the
539 // built-in servers and fail SAFE (unknown → WRITE required, no schema
540 // → no validation skip that grants anything).
541 std::string result_json;
542 {
543 RunSessionScope session_scope(ctx.session_key);
544 result_json = mcp::sanitize_utf8(
545 servers_for(ctx.session_key).execute(call.name, args_json));
546 }
547 auto end = std::chrono::steady_clock::now();
548 auto ms = std::chrono::duration_cast<
549 std::chrono::milliseconds>(end - start).count();
550
551 ctx.metrics.tool_calls++;
552
554
555 // P1-11 (2.0.6-rc16): stash into history ring buffer so the
556 // constitutional validator revision prompt (and diagnostic tools)
557 // can surface prior-iteration tool calls without re-reading
558 // messages[].
559 record_tool_history(call, args_json, result_text, ms,
560 ctx.metrics.iterations);
561
562 fire_tool_complete_callback(call, result_text, ms);
563
564 Message msg;
565 msg.role = "user";
566 msg.content = result_text;
567 msg.metadata["tool_call_id"] = call.id;
568 msg.metadata["tool_name"] = call.name;
569
570 return {std::move(msg), result_json};
571}
572
588void ToolExecutor::record_tool_history(const ToolCall& call,
589 const std::string& args_json,
590 const std::string& result_text,
591 long long ms, int iteration) {
592 ToolCallRecord rec;
593 rec.sequence = ++history_seq_;
594 rec.tool_name = call.name;
595 rec.params_summary = summarize_params(args_json);
596 rec.status = mcp::looks_like_tool_error(result_text) // gh#190: one classifier
597 ? "error" : "success";
598 rec.result_summary = truncate_result(result_text, 200);
599 rec.elapsed_ms = static_cast<double>(ms);
600 rec.iteration = iteration;
601 history_.record(rec);
602}
603
616std::string ToolExecutor::tool_call_key(const ToolCall& call) {
617 // Sort arguments for consistent key. gh#143: seed an OBJECT, not a
618 // default-constructed null — an argument-free call would otherwise
619 // key as "<name>:null" instead of "<name>:{}".
620 nlohmann::json args = nlohmann::json::object();
621 for (const auto& [k, v] : call.arguments) {
622 args[k] = v;
623 }
624 return call.name + ":" + args.dump();
625}
626
639void ToolExecutor::record_tool_call(
640 LoopContext& ctx,
641 const ToolCall& call,
642 const std::string& result) {
643 // Extract result text from JSON envelope
644 std::string text = result;
645 try {
646 auto j = nlohmann::json::parse(result);
647 text = j.value("result", result);
648 } catch (...) {}
649
650 auto key = tool_call_key(call);
651
652 // Don't cache error results — but do COUNT them (v2.13.0). Caching the
653 // text would poison a transient failure for the rest of the turn,
654 // which v1.8.5 rightly avoided; counting bounds the repeat without
655 // taking the retry away. check_repeated_failure reads this.
656 // gh#190 (v2.14.0): ONE error classifier for every site. This used to
657 // match only an "Error:" prefix, so a structured {"error": ...} result
658 // read as success — it cleared the failure count and was cached.
659 if (mcp::looks_like_tool_error(text)) {
660 ctx.failed_tool_calls[key]++;
661 return;
662 }
663 // A success clears the failure history for this exact call: whatever
664 // was wrong is no longer wrong, and a later failure deserves its own
665 // retry rather than inheriting a spent budget.
666 ctx.failed_tool_calls.erase(key);
667 ctx.recent_tool_calls[key] = text;
668}
669
683Message ToolExecutor::create_denied_message(
684 const ToolCall& call,
685 const std::string& reason) {
686 Message msg;
687 msg.role = "user";
688 msg.content =
689 "Tool `" + call.name + "` was denied: " + reason + "\n\n"
690 "This tool is not available to you. Do NOT retry it. "
691 "Use a different approach to accomplish your task.";
692 return msg;
693}
694
705Message ToolExecutor::create_error_message(
706 const ToolCall& call,
707 const std::string& error) {
708 Message msg;
709 msg.role = "user";
710 msg.content =
711 "Tool `" + call.name + "` failed with error: " + error +
712 "\n\nRECOVERY:\n"
713 "- Check arguments are correct\n"
714 "- Try a different approach\n"
715 "- Do NOT retry with the same arguments";
716 return msg;
717}
718
719// ── Private helpers ──────────────────────────────────────
720
727void ToolExecutor::fire_state_callback(const LoopContext& ctx) {
728 if (callbacks_.on_state_change != nullptr) {
729 callbacks_.on_state_change(
730 static_cast<int>(ctx.state), callbacks_.user_data);
731 }
732}
733
743void ToolExecutor::truncate_to_limit(
744 std::vector<ToolCall>& calls,
745 int limit) const {
746 auto lim = static_cast<size_t>(limit);
747 if (calls.size() > lim) {
748 calls.resize(lim);
749 }
750}
751
770std::optional<Message> ToolExecutor::check_mcp_authorization(
771 const LoopContext& ctx,
772 const ToolCall& call) const {
773 if (auth_mgr_ == nullptr) {
774 return std::nullopt;
775 }
776 auto identity = ctx.locked_tier.empty()
777 ? "lead" : ctx.locked_tier;
778 auto required = server_manager_.get_required_access_level(
779 call.name);
780 if (!auth_mgr_->is_enforced(identity) ||
781 auth_mgr_->check_access(identity, call.name, required)) {
782 return std::nullopt;
783 }
785 logger->warn("MCP key denied: {} requires {} for {}",
786 call.name, level_str, identity);
787 Message msg;
788 msg.role = "user";
789 msg.content =
790 "Tool `" + call.name + "` was denied: identity `"
791 + identity + "` lacks " + level_str
792 + " access.\n\n"
793 "Your MCP key set does not authorize this tool. "
794 "Use `entropic.delegate` to hand off to an identity "
795 "that has the required access.";
796 return msg;
797}
798
815std::optional<Message> ToolExecutor::check_dup_or_approval(
816 LoopContext& ctx, const ToolCall& call) {
817 auto dup_result = check_duplicate(ctx, call);
818 if (!dup_result.empty()) {
819 return handle_duplicate(ctx, call, dup_result);
820 }
821 ctx.consecutive_duplicate_attempts = 0;
822 return check_approval(call)
823 ? std::nullopt
824 : std::optional{create_denied_message(
825 call, "Permission denied")};
826}
827
852std::optional<Message> ToolExecutor::check_schema(
853 const ToolCall& call) {
854 auto schema = server_manager_.get_tool_schema(call.name);
855 if (schema.empty()) { return std::nullopt; }
856 auto args = nlohmann::json::parse(
857 serialize_args(call), nullptr, false);
858 auto err = args.is_discarded()
859 ? std::string{} : validate_tool_args(schema, args);
860 if (err.empty()) { return std::nullopt; }
861 logger->warn("Tool '{}' argument validation failed: {}",
862 call.name, err);
863 return create_denied_message(call, err);
864}
865
889PreconditionCheck ToolExecutor::check_call_preconditions(
890 LoopContext& ctx, const ToolCall& call) {
891 PreconditionCheck pc = check_spiral_blocks(ctx, call);
892 if (pc.rejection.has_value()) {
893 return pc;
894 }
895 if (auto r = check_schema(call); r.has_value()) {
896 pc.rejection = std::move(r);
898 } else if (auto a = check_mcp_authorization(ctx, call);
899 a.has_value()) {
900 pc.rejection = std::move(a);
902 } else if (auto t = check_tier_allowed(ctx, call); t.has_value()) {
903 pc.rejection = std::move(t);
905 } else if (auto dup = check_duplicate(ctx, call); !dup.empty()) {
906 pc.rejection = handle_duplicate(ctx, call, dup);
908 } else {
909 pc = check_approval_pc(ctx, call);
910 }
911 return pc;
912}
913
926PreconditionCheck ToolExecutor::check_approval_pc(
927 LoopContext& ctx, const ToolCall& call) {
928 PreconditionCheck pc;
929 ctx.consecutive_duplicate_attempts = 0;
930 if (!check_approval(call)) {
931 pc.rejection = create_denied_message(
932 call, "Permission denied");
934 }
935 return pc;
936}
937
957std::vector<Message> ToolExecutor::process_single_call(
958 LoopContext& ctx, const ToolCall& incoming) {
959 // gh#168 (v2.13.0): the PRE_TOOL_CALL hook may rewrite the call's
960 // ARGUMENTS, so the executor works on its own copy — preconditions,
961 // dispatch, the dup cache and the POST hook all then see one
962 // consistent call rather than the model's superseded version.
963 ToolCall call = incoming;
964 // Hook: PRE_TOOL_CALL first — fires for every attempt, including
965 // those that a precondition will reject. (E9, 2.0.6-rc19)
966 if (fire_pre_tool_hook(ctx, call)) {
967 auto msg = create_denied_message(call, "Cancelled by hook");
968 // gh#84 (v2.5.1): stamp kind so the engine treats this as a
969 // non-progress outcome for the thinking-budget reset.
970 msg.metadata["result_kind"] =
972 fire_post_tool_hook(ctx, call, "", 0.0,
974 return {std::move(msg)};
975 }
976
977 auto pc = check_call_preconditions(ctx, call);
978 if (pc.rejection.has_value()) {
979 logger->info("Tool '{}' rejected by precondition (kind={})",
980 call.name, result_kind_to_string(pc.kind));
981 // gh#84 (v2.5.1): stamp kind for the engine's budget decision.
982 pc.rejection->metadata["result_kind"] =
984 fire_post_tool_hook(ctx, call, "", 0.0, pc.kind, *pc.rejection);
985 return {std::move(*pc.rejection)};
986 }
987
988 auto exec_start = std::chrono::steady_clock::now();
989 auto [msg, raw_result] = execute_tool(ctx, call);
990 auto exec_ms = std::chrono::duration<double, std::milli>(
991 std::chrono::steady_clock::now() - exec_start).count();
992
993 finalize_tool_call(ctx, call, msg, raw_result, exec_ms);
994
995 return {std::move(msg)};
996}
997
1009static ToolResultKind classify_tool_result(const std::string& content) {
1010 if (mcp::looks_like_tool_error(content)) {
1011 return ToolResultKind::error;
1012 }
1013 if (mcp::is_effectively_empty(content)) {
1015 }
1016 return ToolResultKind::ok;
1017}
1018
1039void ToolExecutor::log_tool_call(LoopContext& ctx, const ToolCall& call,
1040 double exec_ms,
1041 const std::string& raw_result,
1042 ToolResultKind kind) {
1043 auto args_log = serialize_args(call);
1044 if (args_log.size() > 512) { args_log.resize(512); }
1045 logger->info("[tool_call] session='{}' iter={} tier={} tool={} args={} "
1046 "elapsed_ms={:.0f} result_chars={} status={}",
1047 ctx.session_key,
1048 ctx.metrics.iterations,
1049 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier,
1050 call.name, args_log, exec_ms,
1051 raw_result.size(), result_kind_to_string(kind));
1052}
1053
1074void ToolExecutor::finalize_tool_call(LoopContext& ctx, const ToolCall& call,
1075 Message& msg,
1076 const std::string& raw_result,
1077 double exec_ms) {
1078 // #46 (v2.1.0): cap result content at LoopConfig.max_tool_result_bytes
1079 // so a single runaway tool can't exhaust the context budget. Applied
1080 // BEFORE classification so kind reflects the bounded form, and BEFORE
1081 // record_tool_call so the duplicate cache stores what the model saw.
1082 apply_result_size_cap(msg.content);
1083 ctx.effective_tool_calls++;
1084 msg.metadata["added_at_iteration"] =
1085 std::to_string(ctx.metrics.iterations);
1086 record_tool_call(ctx, call, raw_result);
1087
1088 // #44 (v2.1.0): honest byte-level signal — error trumps empty.
1089 ToolResultKind kind = classify_tool_result(msg.content);
1090 // gh#84 (v2.5.1): stamp the result kind on the message so the
1091 // engine can tell genuine progress (ok / ok_empty) from rejections
1092 // and errors when deciding whether to reset the thinking budget.
1093 msg.metadata["result_kind"] = result_kind_to_string(kind);
1094 fire_post_tool_hook(ctx, call, raw_result, exec_ms, kind, msg);
1095
1096 // Demo ask #5 (v2.1.0): anti-spiral primitive. Track consecutive
1097 // same-tool calls; at threshold, populate pending_anti_spiral_warning
1098 // so the next turn's reminder tells the model to pivot or complete.
1099 update_anti_spiral_tracking(ctx, call.name);
1100
1101 log_tool_call(ctx, call, exec_ms, raw_result, kind);
1102
1103 extract_and_process_directives(ctx, raw_result);
1104 run_post_tool_hooks(ctx);
1105}
1106
1120static bool pre_mod_renames_tool(const nlohmann::json& parsed,
1121 const std::string& tool_name) {
1122 auto it = parsed.find("tool_name");
1123 return it != parsed.end()
1124 && !(it->is_string() && it->get<std::string>() == tool_name);
1125}
1126
1136static std::string pre_mod_refusal(const nlohmann::json& parsed,
1137 const std::string& tool_name) {
1138 std::string reason;
1139 if (!parsed.is_object()) {
1140 reason = "payload is not a JSON object";
1141 } else if (pre_mod_renames_tool(parsed, tool_name)) {
1142 reason = "payload changes the call's identity — a hook may "
1143 "rewrite arguments, never the tool it routes to";
1144 } else if (!parsed.contains("args")
1145 || !parsed.at("args").is_object()) {
1146 reason = "payload carries no 'args' object";
1147 }
1148 return reason;
1149}
1150
1167 const nlohmann::json& args) {
1168 call.arguments_json = args.dump();
1169 call.arguments.clear();
1170 for (const auto& [k, v] : args.items()) {
1171 call.arguments[k] = v.is_string() ? v.get<std::string>() : v.dump();
1172 }
1173}
1174
1183bool ToolExecutor::apply_pre_tool_modification(ToolCall& call,
1184 const char* modified) {
1185 // A registered hook is a plugin .so — an external boundary in BOTH
1186 // directions (gh#3 / gh#111 / gh#132). Sanitize BEFORE the parse:
1187 // nlohmann rejects an ill-formed UTF-8 byte inside a string, so
1188 // sanitizing afterwards would refuse a payload that is merely dirty.
1189 const std::string sanitized = mcp::sanitize_utf8(modified);
1190 auto parsed = nlohmann::json::parse(sanitized, nullptr, false);
1191 std::string refusal = parsed.is_discarded()
1192 ? std::string{"payload is not valid JSON"}
1193 : pre_mod_refusal(parsed, call.name);
1194 if (!refusal.empty()) {
1195 logger->error(
1196 "[hook] PRE_TOOL_CALL modification REFUSED for '{}': {}. "
1197 "Dispatching the call with the model's own arguments. "
1198 "Payload: {}", call.name, refusal, sanitized);
1199 return false;
1200 }
1202 logger->info("[hook] PRE_TOOL_CALL rewrote args for '{}': {}",
1203 call.name, call.arguments_json);
1204 return true;
1205}
1206
1228bool ToolExecutor::fire_pre_tool_hook(
1229 const LoopContext& ctx, ToolCall& call) {
1230 if (hook_iface_.fire_pre == nullptr) { return false; }
1231 auto json = build_pre_tool_json(call, ctx.locked_tier,
1232 ctx.metrics.iterations);
1233 char* mod = nullptr;
1234 int rc = hook_iface_.fire_pre(hook_iface_.registry,
1235 ENTROPIC_HOOK_PRE_TOOL_CALL, json.c_str(), &mod);
1236 if (mod != nullptr) {
1237 if (rc == 0) { apply_pre_tool_modification(call, mod); }
1238 free(mod);
1239 }
1240 return rc != 0;
1241}
1242
1253void ToolExecutor::apply_result_size_cap(std::string& content) const {
1254 mcp::truncate_to_cap(content, loop_config_.max_tool_result_bytes);
1255}
1256
1272void ToolExecutor::update_anti_spiral_tracking(
1273 LoopContext& ctx, const std::string& tool_name) {
1274 if (tool_name == ctx.last_tool_name) {
1275 ++ctx.consecutive_same_tool_calls;
1276 } else {
1277 ctx.last_tool_name = tool_name;
1278 ctx.consecutive_same_tool_calls = 1;
1279 }
1280 if (ctx.consecutive_same_tool_calls
1281 >= loop_config_.max_consecutive_same_tool) {
1282 ctx.pending_anti_spiral_warning =
1283 tool_name + " has been called "
1284 + std::to_string(ctx.consecutive_same_tool_calls)
1285 + " times consecutively; pivot to a different tool or "
1286 "complete the task next turn.";
1287 }
1288}
1289
1303int ToolExecutor::effective_hard_block_threshold() const {
1304 int configured = loop_config_.max_consecutive_same_tool_hard_block;
1305 if (configured < 0) {
1306 configured = loop_config_.max_consecutive_same_tool + 2;
1307 }
1308 return configured;
1309}
1310
1335PreconditionCheck ToolExecutor::check_anti_spiral_hard_block(
1336 const LoopContext& ctx, const ToolCall& call) const {
1337 PreconditionCheck pc;
1338 int projected = (call.name == ctx.last_tool_name)
1339 ? (ctx.consecutive_same_tool_calls + 1)
1340 : 1;
1341 int threshold = effective_hard_block_threshold();
1342 if (projected >= threshold) {
1343 std::string text =
1344 "[anti-spiral] tool '" + call.name + "' blocked after "
1345 + std::to_string(projected)
1346 + " consecutive calls (threshold "
1347 + std::to_string(threshold)
1348 + "); pivot to a different tool or complete the task.";
1349 pc.rejection = create_denied_message(call, text);
1351 }
1352 return pc;
1353}
1354
1373PreconditionCheck ToolExecutor::check_repeated_failure(
1374 const LoopContext& ctx, const ToolCall& call) const {
1375 PreconditionCheck pc;
1376 auto it = ctx.failed_tool_calls.find(tool_call_key(call));
1377 if (it == ctx.failed_tool_calls.end()) {
1378 return pc;
1379 }
1380 int failures = it->second;
1381 if (failures + 1 >= loop_config_.max_identical_failures) {
1382 std::string text =
1383 "[repeated-failure] tool '" + call.name + "' has already failed "
1384 + std::to_string(failures)
1385 + " times with identical arguments; the failure is not "
1386 "transient. Change the arguments, try a different approach, "
1387 "or report that the task cannot be completed this way.";
1388 pc.rejection = create_denied_message(call, text);
1389 // Deliberately the EXISTING kind, not a new one: repeating a call
1390 // that keeps failing IS a spiral, and the nine result_kind strings
1391 // are a consumer contract (sassafras-class reads them). A tenth
1392 // kind would be a breaking change for a case this one describes.
1394 }
1395 return pc;
1396}
1397
1411PreconditionCheck ToolExecutor::check_spiral_blocks(
1412 const LoopContext& ctx, const ToolCall& call) const {
1413 // Both fire BEFORE schema/auth/dispatch (Issue #14, v2.1.4): they are
1414 // cheaper than those checks and short-circuit a tool the engine has
1415 // already decided to refuse. They are siblings, not duplicates — one
1416 // counts a tool NAME repeating, the other one exact call repeating
1417 // against an identical error, and neither sees the other's case.
1418 PreconditionCheck pc = check_anti_spiral_hard_block(ctx, call);
1419 if (pc.rejection.has_value()) {
1420 return pc;
1421 }
1422 return check_repeated_failure(ctx, call);
1423}
1424
1449void ToolExecutor::fire_post_tool_hook(
1450 const LoopContext& ctx, const ToolCall& call,
1451 const std::string& raw_result, double elapsed_ms,
1452 ToolResultKind kind, Message& msg) {
1453 if (hook_iface_.fire_post == nullptr) { return; }
1454 auto json = build_post_tool_json(
1455 call, raw_result, elapsed_ms, ctx.locked_tier,
1456 ctx.metrics.iterations, kind);
1457 char* out = nullptr;
1458 hook_iface_.fire_post(hook_iface_.registry,
1459 ENTROPIC_HOOK_POST_TOOL_CALL, json.c_str(), &out);
1460 if (out != nullptr) {
1461 // gh#3 recurrence (gh#111, v2.9.7): a hook's transformed result is
1462 // an inbound boundary crossing a plugin .so, same class as
1463 // fire_post_generate_hook/fire_complete_hook in engine.cpp —
1464 // sanitize before it becomes msg.content.
1465 msg.content = mcp::sanitize_utf8(out);
1466 free(out);
1467 }
1468}
1469
1478bool ToolExecutor::should_stop_batch(
1479 const LoopContext& ctx,
1480 const std::vector<Message>& /*results*/) const {
1481 return ctx.state == AgentState::COMPLETE
1482 || ctx.pending_delegation.has_value()
1483 || ctx.pending_pipeline.has_value()
1484 || ctx.consecutive_duplicate_attempts >= 3;
1485}
1486
1493void ToolExecutor::run_post_tool_hooks(LoopContext& ctx) {
1494 if (hooks_.after_tool != nullptr) {
1495 hooks_.after_tool(ctx, hooks_.user_data);
1496 }
1497}
1498
1508Message ToolExecutor::create_circuit_breaker_message() {
1509 Message msg;
1510 msg.role = "user";
1511 msg.content =
1512 "STOP: You have called the same tool 3 times with "
1513 "identical arguments. This indicates you are stuck. "
1514 "Please try a completely different approach or respond "
1515 "to the user explaining what's blocking you.";
1516 logger->error("Circuit breaker triggered");
1517 return msg;
1518}
1519
1532Message ToolExecutor::create_duplicate_message(
1533 const ToolCall& call,
1534 const std::string& previous_result) {
1535 bool was_denied =
1536 previous_result.find("was denied") != std::string::npos
1537 || previous_result.find("not available") != std::string::npos;
1538
1539 Message msg;
1540 msg.role = "user";
1541
1542 if (was_denied) {
1543 msg.content =
1544 "Tool `" + call.name + "` is not available to you "
1545 "and retrying will not help. You MUST use a different "
1546 "approach. Do NOT call `" + call.name + "` again.";
1547 } else {
1548 msg.content =
1549 "Tool `" + call.name + "` was already called with "
1550 "the same arguments.\n\nPrevious result:\n" +
1552 "\n\nDo NOT call this tool again. "
1553 "Use the previous result above.";
1554 }
1555 return msg;
1556}
1557
1589std::string ToolExecutor::serialize_args(const ToolCall& call) {
1590 if (!call.arguments_json.empty()) {
1591 return call.arguments_json;
1592 }
1593 nlohmann::json args = nlohmann::json::object();
1594 for (const auto& [k, v] : call.arguments) {
1595 args[k] = v;
1596 }
1597 return args.dump();
1598}
1599
1607std::string ToolExecutor::serialize_tool_call(const ToolCall& call) {
1608 nlohmann::json j;
1609 j["id"] = call.id;
1610 j["name"] = call.name;
1611 j["arguments"] = nlohmann::json::object();
1612 for (const auto& [k, v] : call.arguments) {
1613 j["arguments"][k] = v;
1614 }
1615 return j.dump();
1616}
1617
1626void ToolExecutor::fire_tool_complete_callback(
1627 const ToolCall& call,
1628 const std::string& result,
1629 long long ms) {
1630 if (callbacks_.on_tool_complete == nullptr) {
1631 return;
1632 }
1633 auto call_json = serialize_tool_call(call);
1634 callbacks_.on_tool_complete(
1635 call_json.c_str(), result.c_str(),
1636 static_cast<double>(ms), callbacks_.user_data);
1637}
1638
1654std::string ToolExecutor::build_post_tool_json(
1655 const ToolCall& call,
1656 const std::string& raw_result,
1657 double elapsed_ms,
1658 const std::string& tier,
1659 int iteration,
1660 ToolResultKind kind) {
1661 nlohmann::json ctx;
1662 ctx["tool_name"] = call.name;
1663 ctx["args"] = nlohmann::json::parse(serialize_args(call));
1664 ctx["elapsed_ms"] = elapsed_ms;
1665 ctx["tier"] = tier.empty() ? std::string{"lead"} : tier;
1666 ctx["iteration"] = iteration;
1667 ctx["result_kind"] = result_kind_to_string(kind);
1668 try {
1669 auto sr = nlohmann::json::parse(raw_result);
1670 ctx["result"] = sr.value("result", raw_result);
1671 ctx["directives"] = sr.value(
1672 "directives", nlohmann::json::array());
1673 } catch (...) {
1674 ctx["result"] = raw_result;
1675 ctx["directives"] = nlohmann::json::array();
1676 }
1677 return ctx.dump();
1678}
1679
1691std::string ToolExecutor::build_pre_tool_json(
1692 const ToolCall& call,
1693 const std::string& tier,
1694 int iteration) {
1695 nlohmann::json j;
1696 j["tool_name"] = call.name;
1697 j["args"] = nlohmann::json::parse(serialize_args(call));
1698 j["tier"] = tier.empty() ? std::string{"lead"} : tier;
1699 j["iteration"] = iteration;
1700 return j.dump();
1701}
1702
1722static std::vector<std::string> extract_pipeline_stages(
1723 const nlohmann::json& result_json) {
1724 std::vector<std::string> stages;
1725 if (!result_json.contains("stages")) { return stages; }
1726 for (const auto& s : result_json["stages"]) {
1727 stages.push_back(s.get<std::string>());
1728 }
1729 return stages;
1730}
1731
1755static std::unique_ptr<Directive> build_complete_directive(
1756 const nlohmann::json& result_json) {
1757 auto cd = std::make_unique<CompleteDirective>(
1758 result_json.value("summary", ""));
1759 cd->coverage_gap = result_json.value("coverage_gap", false);
1760 cd->gap_description = result_json.value("gap_description", "");
1761 if (result_json.contains("suggested_files")
1762 && result_json["suggested_files"].is_array()) {
1763 cd->suggested_files =
1764 result_json["suggested_files"].get<std::vector<std::string>>();
1765 }
1766 return cd;
1767}
1768
1783static std::vector<ContextRef> extract_context_refs(
1784 const nlohmann::json& result_json) {
1785 std::vector<ContextRef> refs;
1786 if (!result_json.contains("context")
1787 || !result_json["context"].is_array()) {
1788 return refs;
1789 }
1790 for (const auto& entry : result_json["context"]) {
1791 if (!entry.is_object()) { continue; }
1793 ref.path = entry.value("path", std::string{});
1794 if (ref.path.empty()) { continue; }
1795 ref.lines = entry.value("lines", std::string{});
1796 ref.note = entry.value("note", std::string{});
1797 refs.push_back(std::move(ref));
1798 }
1799 return refs;
1800}
1801
1814static std::unique_ptr<Directive> build_directive(
1815 const nlohmann::json& d, const nlohmann::json& result_json) {
1816 auto type_str = d.value("type", "");
1817 std::unique_ptr<Directive> result;
1818 if (type_str == "stop_processing") {
1819 result = std::make_unique<StopProcessingDirective>();
1820 } else if (type_str == "delegate") {
1821 // gh#32 (v2.1.6): resume_delegation emits action=resume_delegation
1822 // with delegation_id but no target. The directive's target is
1823 // resolved later by the engine after loading the original
1824 // delegation's tier from storage.
1825 auto dl = std::make_unique<DelegateDirective>(
1826 result_json.value("target", ""),
1827 result_json.value("task", ""),
1828 result_json.value("max_turns", -1),
1829 result_json.value("delegation_id", ""));
1830 // gh#162 (v2.13.0): carry the lead's file references and the
1831 // resume-by-tier flag through to the engine.
1833 dl->resume_by_target = result_json.value("resume_by_target", false);
1834 result = std::move(dl);
1835 } else if (type_str == "complete") {
1837 } else if (type_str == "pipeline") {
1838 auto pl = std::make_unique<PipelineDirective>(
1840 result_json.value("task", ""));
1841 pl->context = extract_context_refs(result_json); // gh#162
1842 result = std::move(pl);
1843 }
1844 return result;
1845}
1846
1857static std::optional<std::pair<nlohmann::json, nlohmann::json>>
1859 auto resp = nlohmann::json::parse(raw_result, nullptr, false);
1860 if (!resp.is_object() || !resp.contains("directives")) {
1861 return std::nullopt;
1862 }
1863 auto dirs = resp["directives"];
1864 if (!dirs.is_array() || dirs.empty()) { return std::nullopt; }
1865 return std::make_pair(std::move(resp), std::move(dirs));
1866}
1867
1881void ToolExecutor::extract_and_process_directives(
1882 LoopContext& ctx, const std::string& raw_result) {
1883 if (hooks_.process_directives == nullptr) { return; }
1885 if (!extracted) { return; }
1886 auto& [resp, dirs] = *extracted;
1887
1888 auto result_json = nlohmann::json::parse(
1889 resp.value("result", "{}"), nullptr, false);
1890
1891 std::vector<std::unique_ptr<Directive>> owned;
1892 for (const auto& d : dirs) {
1894 if (directive) { owned.push_back(std::move(directive)); }
1895 }
1896 if (owned.empty()) { return; }
1897
1898 std::vector<const Directive*> ptrs;
1899 ptrs.reserve(owned.size());
1900 for (const auto& d : owned) { ptrs.push_back(d.get()); }
1901 logger->info("Processing {} directives from tool result", ptrs.size());
1902 hooks_.process_directives(ctx, ptrs, hooks_.user_data);
1903}
1904
1905} // namespace entropic
long long elapsed_ms
Wall time, spawn to reap.
Definition bash.cpp:102
bool is_enforced(const std::string &identity_name) const
Check if an identity has authorization enforcement enabled.
bool check_access(const std::string &identity_name, const std::string &tool_name, MCPAccessLevel required_level) const
Check if a tool call is authorized for an identity.
Manages MCP server instances and routes tool calls.
MCPAccessLevel get_required_access_level(const std::string &tool_name) const
Get the required access level for a tool.
bool is_explicitly_allowed(const std::string &tool_name, const std::string &args_json) const
Check if tool is explicitly allowed (skip prompting).
bool skip_duplicate_check(const std::string &tool_name) const
Check if tool should skip duplicate detection.
std::string get_tool_schema(const std::string &tool_name) const
Get the JSON Schema for a tool's input parameters.
std::string execute(const std::string &tool_name, const std::string &args_json)
Execute a tool call via the appropriate server.
void record(const ToolCallRecord &entry)
Record a completed tool call.
std::vector< Message > process_tool_calls(LoopContext &ctx, const std::vector< ToolCall > &tool_calls)
Process a batch of tool calls.
ToolExecutor(ServerManager &server_manager, const LoopConfig &loop_config, EngineCallbacks &callbacks, ToolExecutorHooks hooks={})
Construct with shared dependencies.
void set_server_resolver(ServerManager *(*fn)(const std::string &session_key, void *user_data), void *user_data)
Resolve a session's MCP servers (gh#166, v2.13.0).
void set_permission_persist(const PermissionPersistInterface &persist)
Set permission persistence interface.
std::string err
size_exceeded, when set
long limit
Results to keep.
@ ENTROPIC_HOOK_ON_PERMISSION_CHECK
15: Permission check evaluated
Definition hooks.h:56
@ ENTROPIC_HOOK_PRE_TOOL_CALL
3: Before tool execution
Definition hooks.h:44
@ ENTROPIC_HOOK_POST_TOOL_CALL
4: After tool execution returns
Definition hooks.h:45
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 check_type(const std::string &key, const std::string &type, const nlohmann::json &val)
Check a single value against a type constraint.
ToolResultKind
Categorical outcome of a single tool invocation.
Definition tool_result.h:32
@ ok
Tool dispatched, returned non-empty content.
@ rejected_schema
Precondition: argument schema violation.
@ rejected_anti_spiral
Anti-spiral hard threshold crossed; tool blocked. (#14, v2.1.4)
@ rejected_duplicate
Precondition: duplicate in recent history.
@ ok_empty
Tool dispatched cleanly but returned no content (v2.1.0, #44)
@ rejected_unauthorized
Tool not in the locked tier's allowed_tools. (gh#83, v2.5.2)
@ error
Tool server returned an error payload.
@ rejected_precondition
Any other precondition reject (auth, tier, hook-cancel)
@ approved
Serve this one call.
@ optional
optional: every escape asks the host's path approver
static std::string parse_tool_result_text(const std::string &result_json)
Extract the "result" text from an MCP result JSON envelope.
static bool pre_mod_renames_tool(const nlohmann::json &parsed, const std::string &tool_name)
Does a PRE_TOOL_CALL payload try to change the call's identity?
@ tier
The TIER's identity frontmatter grammar: stem (gh#154).
@ count
Sentinel — MUST remain last.
static std::string check_range(const std::string &key, const nlohmann::json &prop, const nlohmann::json &val)
Check a numeric value against minimum / maximum (v2.14.0).
static std::vector< std::string > extract_pipeline_stages(const nlohmann::json &result_json)
Extract directives from ServerResponse JSON and process them.
static std::string check_property_constraints(const std::string &key, const nlohmann::json &prop, const nlohmann::json &val)
Check one property's enum, type and range constraints.
static ToolResultKind classify_tool_result(const std::string &content)
Classify a tool result by its content (error/empty/ok).
const char * mcp_access_level_name(MCPAccessLevel level)
Convert MCPAccessLevel to string representation.
Definition config.cpp:21
static std::unique_ptr< Directive > build_directive(const nlohmann::json &d, const nlohmann::json &result_json)
Build a Directive from a parsed directive + result JSON.
const char * result_kind_to_string(ToolResultKind kind)
Serialize a ToolResultKind to its wire-stable string form.
Definition tool_result.h:51
static std::unique_ptr< Directive > build_complete_directive(const nlohmann::json &result_json)
Build a typed Directive from a directive-descriptor JSON.
static std::optional< std::pair< nlohmann::json, nlohmann::json > > extract_directive_array(const std::string &raw_result)
Pull the "directives" array out of a tool ServerResponse JSON.
std::string truncate_result(const std::string &text, size_t max_len)
Truncate a string to max_len characters with "..." suffix.
static char * dup(const std::string &s)
Heap-allocate a C string copy.
static std::string pre_mod_refusal(const nlohmann::json &parsed, const std::string &tool_name)
Why a PRE_TOOL_CALL modification must be refused (gh#168).
static std::vector< ContextRef > extract_context_refs(const nlohmann::json &result_json)
Parse the context array of a delegate/pipeline result (gh#162).
std::string summarize_params(const std::string &args_json)
Extract top-level JSON keys as a comma-separated summary.
static std::string validate_tool_args(const std::string &schema_json, const nlohmann::json &args)
Validate tool arguments against the tool's JSON Schema.
static std::string check_enum(const std::string &key, const nlohmann::json &allowed, const nlohmann::json &val)
Check one property's enum and type constraints.
static void overwrite_call_arguments(ToolCall &call, const nlohmann::json &args)
Replace a call's arguments from a validated args object.
static std::string check_required_fields(const nlohmann::json &schema, const nlohmann::json &args)
Check required fields are present.
gh#158: the cancel token of the run executing on THIS thread.
One file the lead already paid to find (gh#162, v2.13.0).
Definition directives.h:84
std::string path
Repo-relative (or absolute) file path.
Definition directives.h:85
Callback function pointer types for engine events.
void(* on_tool_call)(const char *json, void *ud)
Tool call request.
void(* on_tool_complete)(const char *json, const char *result, double ms, void *ud)
Tool execution done.
void * user_data
Opaque pointer passed to all callbacks.
void(* on_tool_start)(const char *json, void *ud)
Tool execution start.
void(* on_state_change)(int state, void *ud)
AgentState as int.
Configuration for the agentic loop.
int max_consecutive_same_tool
Anti-spiral SOFT threshold: after N consecutive calls of the SAME tool (regardless of arg similarity,...
int max_consecutive_same_tool_hard_block
Anti-spiral HARD threshold: when consecutive same-tool calls exceed this, the engine blocks the call ...
bool auto_approve_tools
Skip tool approval (v1.8.5)
int max_tool_result_bytes
Maximum byte length for a single tool's result content before the engine truncates with a "[....
int max_tool_calls_per_turn
Tool calls per iteration (v1.8.5)
int max_identical_failures
How many times one EXACT call (tool + sorted arguments) may fail before the engine refuses it pre-dis...
Mutable state carried through the agentic loop.
Permission persistence interface.
A tool call request parsed from model output.
Definition tool_call.h:31
Engine-level hooks called during tool processing.
void(* after_tool)(LoopContext &ctx, void *user_data)
Called after each tool execution.
DirectiveResult(* process_directives)(LoopContext &ctx, const std::vector< const Directive * > &directives, void *user_data)
Process directives from tool results.
void * user_data
Opaque pointer for hooks.
Processes tool calls from model output.
Byte-level classifiers for tool-result content (#44, v2.1.0).
UTF-8 validation + replacement at every system boundary where bytes change ownership.