Entropic 2.11.1
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
12
13#include <nlohmann/json.hpp>
14
15#include <algorithm>
16#include <chrono>
17#include <memory>
18#include <optional>
19
20static auto logger = entropic::log::get("mcp.tool_executor");
21
22namespace entropic {
23
34 ServerManager& server_manager,
35 const LoopConfig& loop_config,
36 EngineCallbacks& callbacks,
38 : server_manager_(server_manager),
39 loop_config_(loop_config),
40 callbacks_(callbacks),
41 hooks_(hooks) {}
42
50 const PermissionPersistInterface& persist) {
51 permission_persist_ = persist;
52}
53
71 LoopContext& ctx,
72 const std::vector<ToolCall>& tool_calls) {
73 logger->info("Processing {} tool calls", tool_calls.size());
74 ctx.state = AgentState::WAITING_TOOL;
75 fire_state_callback(ctx);
76
77 auto limited = sort_tool_calls(tool_calls);
78 int eff_limit = ctx.effective_max_tool_calls_per_turn >= 0
80 : loop_config_.max_tool_calls_per_turn;
81 truncate_to_limit(limited, eff_limit);
82
83 std::vector<Message> results;
84 for (const auto& call : limited) {
85 auto msgs = process_single_call(ctx, call);
86 for (auto& m : msgs) {
87 results.push_back(std::move(m));
88 }
89 if (should_stop_batch(ctx, results)) {
90 break;
91 }
92 }
93
94 ctx.consecutive_errors = 0;
95 return results;
96}
97
110std::vector<ToolCall> ToolExecutor::sort_tool_calls(
111 const std::vector<ToolCall>& calls) {
112 auto sorted = calls;
113 std::stable_sort(sorted.begin(), sorted.end(),
114 [](const ToolCall& a, const ToolCall& b) {
115 bool a_delegate = (a.name == "entropic.delegate");
116 bool b_delegate = (b.name == "entropic.delegate");
117 return !a_delegate && b_delegate;
118 });
119 return sorted;
120}
121
137std::string ToolExecutor::check_duplicate(
138 const LoopContext& ctx,
139 const ToolCall& call) const {
140 if (server_manager_.skip_duplicate_check(call.name)) {
141 return "";
142 }
143 auto key = tool_call_key(call);
144 auto it = ctx.recent_tool_calls.find(key);
145 if (it != ctx.recent_tool_calls.end()) {
146 return it->second;
147 }
148 return "";
149}
150
167Message ToolExecutor::handle_duplicate(
168 LoopContext& ctx,
169 const ToolCall& call,
170 const std::string& previous_result) {
171 ctx.consecutive_duplicate_attempts++;
172 logger->warn("Duplicate tool call #{}: {}",
173 ctx.consecutive_duplicate_attempts, call.name);
174
175 if (ctx.consecutive_duplicate_attempts >= 3) {
176 return create_circuit_breaker_message();
177 }
178 return create_duplicate_message(call, previous_result);
179}
180
197bool ToolExecutor::check_approval(const ToolCall& call) {
198 auto args_json = serialize_args(call);
199 bool auto_ok = loop_config_.auto_approve_tools
200 || server_manager_.is_explicitly_allowed(
201 call.name, args_json);
202
203 bool approved = auto_ok;
204 if (!approved && callbacks_.on_tool_call != nullptr) {
205 auto call_json = serialize_tool_call(call);
206 callbacks_.on_tool_call(call_json.c_str(),
207 callbacks_.user_data);
208 approved = true;
209 }
210
211 // Hook: ON_PERMISSION_CHECK — informational (v1.9.1)
212 if (hook_iface_.fire_info != nullptr) {
213 std::string perm = approved ? "allowed" : "denied";
214 std::string json = "{\"tool_name\":\""
215 + call.name + "\",\"permission\":\"" + perm + "\"}";
216 hook_iface_.fire_info(hook_iface_.registry,
218 }
219
220 if (!approved) {
221 logger->warn("No approval callback — denying: {}", call.name);
222 }
223 return approved;
224}
225
238std::optional<Message> ToolExecutor::check_tier_allowed(
239 const LoopContext& ctx, const ToolCall& call) const {
240 // gh#83 (v2.5.2): enforce the locked tier's allowed_tools at
241 // dispatch — not just at prompt-injection time. Without this a
242 // model that emits an off-allowlist call (hallucinated, learned,
243 // or cross-tier) has it dispatched normally; tier isolation was
244 // advisory only. Pass through when: no tier locked, no map wired,
245 // or the tier declares no allowlist (unrestricted).
246 if (ctx.locked_tier.empty() || tier_allowed_tools_ == nullptr) {
247 return std::nullopt;
248 }
249 auto it = tier_allowed_tools_->find(ctx.locked_tier);
250 // Authorized when: tier has no allowlist entry, an empty allowlist
251 // (unrestricted), or the call is in the list. Short-circuit eval
252 // guards the `it->second` access against the end() case.
253 bool authorized = it == tier_allowed_tools_->end()
254 || it->second.empty()
255 || std::find(it->second.begin(), it->second.end(),
256 call.name) != it->second.end();
257 if (authorized) {
258 return std::nullopt;
259 }
260 logger->warn("Tool '{}' not in tier '{}' allowed_tools — rejecting",
261 call.name, ctx.locked_tier);
262 return create_denied_message(
263 call, "tier '" + ctx.locked_tier + "' is not authorized to call '"
264 + call.name + "'");
265}
266
277static std::string check_required_fields(
278 const nlohmann::json& schema,
279 const nlohmann::json& args)
280{
281 for (const auto& req : schema.value("required",
282 nlohmann::json::array())) {
283 if (!args.contains(req.get<std::string>())) {
284 return "Missing required argument: "
285 + req.get<std::string>();
286 }
287 }
288 return "";
289}
290
311static std::string check_enum(
312 const std::string& key,
313 const nlohmann::json& allowed,
314 const nlohmann::json& val)
315{
316 for (const auto& e : allowed) {
317 if (e == val) { return ""; }
318 }
319 return "Invalid value for '" + key + "': "
320 + val.dump() + ". Must be one of: " + allowed.dump();
321}
322
334static std::string check_type(
335 const std::string& key,
336 const std::string& type,
337 const nlohmann::json& val)
338{
339 bool ok = (type == "string" && val.is_string())
340 || (type == "integer" && val.is_number_integer())
341 || (type == "number" && val.is_number())
342 || (type == "boolean" && val.is_boolean())
343 || (type == "array" && val.is_array())
344 || (type == "object" && val.is_object());
345 return ok ? "" : "Type mismatch for '" + key
346 + "': expected " + type;
347}
348
360static std::string check_property_constraints(
361 const std::string& key,
362 const nlohmann::json& prop,
363 const nlohmann::json& val)
364{
365 if (prop.contains("enum")) {
366 auto err = check_enum(key, prop["enum"], val);
367 if (!err.empty()) { return err; }
368 }
369 if (!prop.contains("type")) { return ""; }
370 return check_type(key, prop["type"].get<std::string>(), val);
371}
372
389static std::string validate_tool_args(
390 const std::string& schema_json,
391 const nlohmann::json& args)
392{
393 auto schema = nlohmann::json::parse(schema_json, nullptr, false);
394 if (!schema.is_object()) { return ""; }
395
396 auto err = check_required_fields(schema, args);
397 auto props = schema.value("properties", nlohmann::json::object());
398 for (auto it = props.begin(); it != props.end() && err.empty(); ++it) {
399 if (args.contains(it.key())) {
401 it.key(), it.value(), args[it.key()]);
402 }
403 }
404 return err;
405}
406
416static std::string parse_tool_result_text(const std::string& result_json) {
417 try {
418 auto j = nlohmann::json::parse(result_json);
419 return j.value("result", result_json);
420 } catch (...) {
421 return result_json;
422 }
423}
424
443std::pair<Message, std::string> ToolExecutor::execute_tool(
444 LoopContext& ctx, const ToolCall& call) {
445
446 auto args_json = serialize_args(call);
447
448 if (callbacks_.on_tool_start != nullptr) {
449 auto call_json = serialize_tool_call(call);
450 callbacks_.on_tool_start(call_json.c_str(),
451 callbacks_.user_data);
452 }
453
454 auto start = std::chrono::steady_clock::now();
455 // Inbound boundary from MCP server subprocess. v2.1.0 (#47) introduced
456 // this; v2.1.1 (#3) generalized it as one of several boundary-policy
457 // sanitize sites — see include/entropic/mcp/utf8_sanitize.h for the
458 // full policy. The earlier "trust downstream" assumption was wrong:
459 // bytes also enter via the model token stream and the audit-replay
460 // path; both now sanitize at their own boundaries.
461 auto result_json = mcp::sanitize_utf8(
462 server_manager_.execute(call.name, args_json));
463 auto end = std::chrono::steady_clock::now();
464 auto ms = std::chrono::duration_cast<
465 std::chrono::milliseconds>(end - start).count();
466
467 ctx.metrics.tool_calls++;
468
469 std::string result_text = parse_tool_result_text(result_json);
470
471 // P1-11 (2.0.6-rc16): stash into history ring buffer so the
472 // constitutional validator revision prompt (and diagnostic tools)
473 // can surface prior-iteration tool calls without re-reading
474 // messages[].
475 record_tool_history(call, args_json, result_text, ms,
476 ctx.metrics.iterations);
477
478 fire_tool_complete_callback(call, result_text, ms);
479
480 Message msg;
481 msg.role = "user";
482 msg.content = result_text;
483 msg.metadata["tool_call_id"] = call.id;
484 msg.metadata["tool_name"] = call.name;
485
486 return {std::move(msg), result_json};
487}
488
504void ToolExecutor::record_tool_history(const ToolCall& call,
505 const std::string& args_json,
506 const std::string& result_text,
507 long long ms, int iteration) {
508 ToolCallRecord rec;
509 rec.sequence = ++history_seq_;
510 rec.tool_name = call.name;
511 rec.params_summary = summarize_params(args_json);
512 rec.status = (result_text.rfind("error", 0) == 0)
513 ? "error" : "success";
514 rec.result_summary = truncate_result(result_text, 200);
515 rec.elapsed_ms = static_cast<double>(ms);
516 rec.iteration = iteration;
517 history_.record(rec);
518}
519
532std::string ToolExecutor::tool_call_key(const ToolCall& call) {
533 // Sort arguments for consistent key
534 nlohmann::json args;
535 for (const auto& [k, v] : call.arguments) {
536 args[k] = v;
537 }
538 return call.name + ":" + args.dump();
539}
540
553void ToolExecutor::record_tool_call(
554 LoopContext& ctx,
555 const ToolCall& call,
556 const std::string& result) {
557 // Extract result text from JSON envelope
558 std::string text = result;
559 try {
560 auto j = nlohmann::json::parse(result);
561 text = j.value("result", result);
562 } catch (...) {}
563
564 // Don't cache error results
565 if (text.find("Error:") == 0 || text.find("error:") == 0) {
566 return;
567 }
568 auto key = tool_call_key(call);
569 ctx.recent_tool_calls[key] = text;
570}
571
585Message ToolExecutor::create_denied_message(
586 const ToolCall& call,
587 const std::string& reason) {
588 Message msg;
589 msg.role = "user";
590 msg.content =
591 "Tool `" + call.name + "` was denied: " + reason + "\n\n"
592 "This tool is not available to you. Do NOT retry it. "
593 "Use a different approach to accomplish your task.";
594 return msg;
595}
596
607Message ToolExecutor::create_error_message(
608 const ToolCall& call,
609 const std::string& error) {
610 Message msg;
611 msg.role = "user";
612 msg.content =
613 "Tool `" + call.name + "` failed with error: " + error +
614 "\n\nRECOVERY:\n"
615 "- Check arguments are correct\n"
616 "- Try a different approach\n"
617 "- Do NOT retry with the same arguments";
618 return msg;
619}
620
621// ── Private helpers ──────────────────────────────────────
622
629void ToolExecutor::fire_state_callback(const LoopContext& ctx) {
630 if (callbacks_.on_state_change != nullptr) {
631 callbacks_.on_state_change(
632 static_cast<int>(ctx.state), callbacks_.user_data);
633 }
634}
635
645void ToolExecutor::truncate_to_limit(
646 std::vector<ToolCall>& calls,
647 int limit) const {
648 auto lim = static_cast<size_t>(limit);
649 if (calls.size() > lim) {
650 calls.resize(lim);
651 }
652}
653
672std::optional<Message> ToolExecutor::check_mcp_authorization(
673 const LoopContext& ctx,
674 const ToolCall& call) const {
675 if (auth_mgr_ == nullptr) {
676 return std::nullopt;
677 }
678 auto identity = ctx.locked_tier.empty()
679 ? "lead" : ctx.locked_tier;
680 auto required = server_manager_.get_required_access_level(
681 call.name);
682 if (!auth_mgr_->is_enforced(identity) ||
683 auth_mgr_->check_access(identity, call.name, required)) {
684 return std::nullopt;
685 }
686 auto level_str = mcp_access_level_name(required);
687 logger->warn("MCP key denied: {} requires {} for {}",
688 call.name, level_str, identity);
689 Message msg;
690 msg.role = "user";
691 msg.content =
692 "Tool `" + call.name + "` was denied: identity `"
693 + identity + "` lacks " + level_str
694 + " access.\n\n"
695 "Your MCP key set does not authorize this tool. "
696 "Use `entropic.delegate` to hand off to an identity "
697 "that has the required access.";
698 return msg;
699}
700
717std::optional<Message> ToolExecutor::check_dup_or_approval(
718 LoopContext& ctx, const ToolCall& call) {
719 auto dup_result = check_duplicate(ctx, call);
720 if (!dup_result.empty()) {
721 return handle_duplicate(ctx, call, dup_result);
722 }
723 ctx.consecutive_duplicate_attempts = 0;
724 return check_approval(call)
725 ? std::nullopt
726 : std::optional{create_denied_message(
727 call, "Permission denied")};
728}
729
754std::optional<Message> ToolExecutor::check_schema(
755 const ToolCall& call) {
756 auto schema = server_manager_.get_tool_schema(call.name);
757 if (schema.empty()) { return std::nullopt; }
758 auto args = nlohmann::json::parse(
759 serialize_args(call), nullptr, false);
760 auto err = args.is_discarded()
761 ? std::string{} : validate_tool_args(schema, args);
762 if (err.empty()) { return std::nullopt; }
763 logger->warn("Tool '{}' argument validation failed: {}",
764 call.name, err);
765 return create_denied_message(call, err);
766}
767
791PreconditionCheck ToolExecutor::check_call_preconditions(
792 LoopContext& ctx, const ToolCall& call) {
793 // Issue #14 (v2.1.4): anti-spiral hard block fires FIRST. Cheaper
794 // than schema/auth checks and short-circuits a tool that the
795 // engine has decided to refuse, regardless of whether the call
796 // would otherwise pass other preconditions.
797 PreconditionCheck pc = check_anti_spiral_hard_block(ctx, call);
798 if (pc.rejection.has_value()) {
799 return pc;
800 }
801 if (auto r = check_schema(call); r.has_value()) {
802 pc.rejection = std::move(r);
804 } else if (auto a = check_mcp_authorization(ctx, call);
805 a.has_value()) {
806 pc.rejection = std::move(a);
808 } else if (auto t = check_tier_allowed(ctx, call); t.has_value()) {
809 pc.rejection = std::move(t);
810 pc.kind = ToolResultKind::rejected_unauthorized; // gh#83
811 } else if (auto dup = check_duplicate(ctx, call); !dup.empty()) {
812 pc.rejection = handle_duplicate(ctx, call, dup);
814 } else {
815 pc = check_approval_pc(ctx, call);
816 }
817 return pc;
818}
819
832PreconditionCheck ToolExecutor::check_approval_pc(
833 LoopContext& ctx, const ToolCall& call) {
834 PreconditionCheck pc;
835 ctx.consecutive_duplicate_attempts = 0;
836 if (!check_approval(call)) {
837 pc.rejection = create_denied_message(
838 call, "Permission denied");
840 }
841 return pc;
842}
843
863std::vector<Message> ToolExecutor::process_single_call(
864 LoopContext& ctx, const ToolCall& call) {
865 // Hook: PRE_TOOL_CALL first — fires for every attempt, including
866 // those that a precondition will reject. (E9, 2.0.6-rc19)
867 if (fire_pre_tool_hook(ctx, call)) {
868 auto msg = create_denied_message(call, "Cancelled by hook");
869 // gh#84 (v2.5.1): stamp kind so the engine treats this as a
870 // non-progress outcome for the thinking-budget reset.
871 msg.metadata["result_kind"] =
873 fire_post_tool_hook(ctx, call, "", 0.0,
875 return {std::move(msg)};
876 }
877
878 auto pc = check_call_preconditions(ctx, call);
879 if (pc.rejection.has_value()) {
880 logger->info("Tool '{}' rejected by precondition (kind={})",
881 call.name, result_kind_to_string(pc.kind));
882 // gh#84 (v2.5.1): stamp kind for the engine's budget decision.
883 pc.rejection->metadata["result_kind"] =
884 result_kind_to_string(pc.kind);
885 fire_post_tool_hook(ctx, call, "", 0.0, pc.kind, *pc.rejection);
886 return {std::move(*pc.rejection)};
887 }
888
889 auto exec_start = std::chrono::steady_clock::now();
890 auto [msg, raw_result] = execute_tool(ctx, call);
891 auto exec_ms = std::chrono::duration<double, std::milli>(
892 std::chrono::steady_clock::now() - exec_start).count();
893
894 finalize_tool_call(ctx, call, msg, raw_result, exec_ms);
895
896 return {std::move(msg)};
897}
898
910static ToolResultKind classify_tool_result(const std::string& content) {
911 if (mcp::looks_like_tool_error(content)) {
913 }
914 if (mcp::is_effectively_empty(content)) {
916 }
917 return ToolResultKind::ok;
918}
919
930void ToolExecutor::log_tool_call(LoopContext& ctx, const ToolCall& call,
931 double exec_ms,
932 const std::string& raw_result,
933 ToolResultKind kind) {
934 auto args_log = serialize_args(call);
935 if (args_log.size() > 512) { args_log.resize(512); }
936 logger->info("[tool_call] iter={} tier={} tool={} args={} "
937 "elapsed_ms={:.0f} result_chars={} status={}",
938 ctx.metrics.iterations,
939 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier,
940 call.name, args_log, exec_ms,
941 raw_result.size(), result_kind_to_string(kind));
942}
943
964void ToolExecutor::finalize_tool_call(LoopContext& ctx, const ToolCall& call,
965 Message& msg,
966 const std::string& raw_result,
967 double exec_ms) {
968 // #46 (v2.1.0): cap result content at LoopConfig.max_tool_result_bytes
969 // so a single runaway tool can't exhaust the context budget. Applied
970 // BEFORE classification so kind reflects the bounded form, and BEFORE
971 // record_tool_call so the duplicate cache stores what the model saw.
972 apply_result_size_cap(msg.content);
973 ctx.effective_tool_calls++;
974 msg.metadata["added_at_iteration"] =
975 std::to_string(ctx.metrics.iterations);
976 record_tool_call(ctx, call, raw_result);
977
978 // #44 (v2.1.0): honest byte-level signal — error trumps empty.
979 ToolResultKind kind = classify_tool_result(msg.content);
980 // gh#84 (v2.5.1): stamp the result kind on the message so the
981 // engine can tell genuine progress (ok / ok_empty) from rejections
982 // and errors when deciding whether to reset the thinking budget.
983 msg.metadata["result_kind"] = result_kind_to_string(kind);
984 fire_post_tool_hook(ctx, call, raw_result, exec_ms, kind, msg);
985
986 // Demo ask #5 (v2.1.0): anti-spiral primitive. Track consecutive
987 // same-tool calls; at threshold, populate pending_anti_spiral_warning
988 // so the next turn's reminder tells the model to pivot or complete.
989 update_anti_spiral_tracking(ctx, call.name);
990
991 log_tool_call(ctx, call, exec_ms, raw_result, kind);
992
993 extract_and_process_directives(ctx, raw_result);
994 run_post_tool_hooks(ctx);
995}
996
1012bool ToolExecutor::fire_pre_tool_hook(
1013 const LoopContext& ctx, const ToolCall& call) {
1014 if (hook_iface_.fire_pre == nullptr) { return false; }
1015 auto json = build_pre_tool_json(call, ctx.locked_tier,
1016 ctx.metrics.iterations);
1017 char* mod = nullptr;
1018 int rc = hook_iface_.fire_pre(hook_iface_.registry,
1019 ENTROPIC_HOOK_PRE_TOOL_CALL, json.c_str(), &mod);
1020 free(mod);
1021 return rc != 0;
1022}
1023
1034void ToolExecutor::apply_result_size_cap(std::string& content) const {
1035 mcp::truncate_to_cap(content, loop_config_.max_tool_result_bytes);
1036}
1037
1053void ToolExecutor::update_anti_spiral_tracking(
1054 LoopContext& ctx, const std::string& tool_name) {
1055 if (tool_name == ctx.last_tool_name) {
1056 ++ctx.consecutive_same_tool_calls;
1057 } else {
1058 ctx.last_tool_name = tool_name;
1059 ctx.consecutive_same_tool_calls = 1;
1060 }
1061 if (ctx.consecutive_same_tool_calls
1062 >= loop_config_.max_consecutive_same_tool) {
1063 ctx.pending_anti_spiral_warning =
1064 tool_name + " has been called "
1065 + std::to_string(ctx.consecutive_same_tool_calls)
1066 + " times consecutively; pivot to a different tool or "
1067 "complete the task next turn.";
1068 }
1069}
1070
1084int ToolExecutor::effective_hard_block_threshold() const {
1085 int configured = loop_config_.max_consecutive_same_tool_hard_block;
1086 if (configured < 0) {
1087 configured = loop_config_.max_consecutive_same_tool + 2;
1088 }
1089 return configured;
1090}
1091
1116PreconditionCheck ToolExecutor::check_anti_spiral_hard_block(
1117 const LoopContext& ctx, const ToolCall& call) const {
1118 PreconditionCheck pc;
1119 int projected = (call.name == ctx.last_tool_name)
1120 ? (ctx.consecutive_same_tool_calls + 1)
1121 : 1;
1122 int threshold = effective_hard_block_threshold();
1123 if (projected >= threshold) {
1124 std::string text =
1125 "[anti-spiral] tool '" + call.name + "' blocked after "
1126 + std::to_string(projected)
1127 + " consecutive calls (threshold "
1128 + std::to_string(threshold)
1129 + "); pivot to a different tool or complete the task.";
1130 pc.rejection = create_denied_message(call, text);
1132 }
1133 return pc;
1134}
1135
1160void ToolExecutor::fire_post_tool_hook(
1161 const LoopContext& ctx, const ToolCall& call,
1162 const std::string& raw_result, double elapsed_ms,
1163 ToolResultKind kind, Message& msg) {
1164 if (hook_iface_.fire_post == nullptr) { return; }
1165 auto json = build_post_tool_json(
1166 call, raw_result, elapsed_ms, ctx.locked_tier,
1167 ctx.metrics.iterations, kind);
1168 char* out = nullptr;
1169 hook_iface_.fire_post(hook_iface_.registry,
1170 ENTROPIC_HOOK_POST_TOOL_CALL, json.c_str(), &out);
1171 if (out != nullptr) {
1172 // gh#3 recurrence (gh#111, v2.9.7): a hook's transformed result is
1173 // an inbound boundary crossing a plugin .so, same class as
1174 // fire_post_generate_hook/fire_complete_hook in engine.cpp —
1175 // sanitize before it becomes msg.content.
1176 msg.content = mcp::sanitize_utf8(out);
1177 free(out);
1178 }
1179}
1180
1189bool ToolExecutor::should_stop_batch(
1190 const LoopContext& ctx,
1191 const std::vector<Message>& /*results*/) const {
1192 return ctx.state == AgentState::COMPLETE
1193 || ctx.pending_delegation.has_value()
1194 || ctx.pending_pipeline.has_value()
1195 || ctx.consecutive_duplicate_attempts >= 3;
1196}
1197
1204void ToolExecutor::run_post_tool_hooks(LoopContext& ctx) {
1205 if (hooks_.after_tool != nullptr) {
1206 hooks_.after_tool(ctx, hooks_.user_data);
1207 }
1208}
1209
1219Message ToolExecutor::create_circuit_breaker_message() {
1220 Message msg;
1221 msg.role = "user";
1222 msg.content =
1223 "STOP: You have called the same tool 3 times with "
1224 "identical arguments. This indicates you are stuck. "
1225 "Please try a completely different approach or respond "
1226 "to the user explaining what's blocking you.";
1227 logger->error("Circuit breaker triggered");
1228 return msg;
1229}
1230
1243Message ToolExecutor::create_duplicate_message(
1244 const ToolCall& call,
1245 const std::string& previous_result) {
1246 bool was_denied =
1247 previous_result.find("was denied") != std::string::npos
1248 || previous_result.find("not available") != std::string::npos;
1249
1250 Message msg;
1251 msg.role = "user";
1252
1253 if (was_denied) {
1254 msg.content =
1255 "Tool `" + call.name + "` is not available to you "
1256 "and retrying will not help. You MUST use a different "
1257 "approach. Do NOT call `" + call.name + "` again.";
1258 } else {
1259 msg.content =
1260 "Tool `" + call.name + "` was already called with "
1261 "the same arguments.\n\nPrevious result:\n" +
1262 previous_result +
1263 "\n\nDo NOT call this tool again. "
1264 "Use the previous result above.";
1265 }
1266 return msg;
1267}
1268
1284std::string ToolExecutor::serialize_args(const ToolCall& call) {
1285 if (!call.arguments_json.empty()) {
1286 return call.arguments_json;
1287 }
1288 nlohmann::json args;
1289 for (const auto& [k, v] : call.arguments) {
1290 args[k] = v;
1291 }
1292 return args.dump();
1293}
1294
1302std::string ToolExecutor::serialize_tool_call(const ToolCall& call) {
1303 nlohmann::json j;
1304 j["id"] = call.id;
1305 j["name"] = call.name;
1306 j["arguments"] = nlohmann::json::object();
1307 for (const auto& [k, v] : call.arguments) {
1308 j["arguments"][k] = v;
1309 }
1310 return j.dump();
1311}
1312
1321void ToolExecutor::fire_tool_complete_callback(
1322 const ToolCall& call,
1323 const std::string& result,
1324 long long ms) {
1325 if (callbacks_.on_tool_complete == nullptr) {
1326 return;
1327 }
1328 auto call_json = serialize_tool_call(call);
1329 callbacks_.on_tool_complete(
1330 call_json.c_str(), result.c_str(),
1331 static_cast<double>(ms), callbacks_.user_data);
1332}
1333
1349std::string ToolExecutor::build_post_tool_json(
1350 const ToolCall& call,
1351 const std::string& raw_result,
1352 double elapsed_ms,
1353 const std::string& tier,
1354 int iteration,
1355 ToolResultKind kind) {
1356 nlohmann::json ctx;
1357 ctx["tool_name"] = call.name;
1358 ctx["args"] = nlohmann::json::parse(serialize_args(call));
1359 ctx["elapsed_ms"] = elapsed_ms;
1360 ctx["tier"] = tier.empty() ? std::string{"lead"} : tier;
1361 ctx["iteration"] = iteration;
1362 ctx["result_kind"] = result_kind_to_string(kind);
1363 try {
1364 auto sr = nlohmann::json::parse(raw_result);
1365 ctx["result"] = sr.value("result", raw_result);
1366 ctx["directives"] = sr.value(
1367 "directives", nlohmann::json::array());
1368 } catch (...) {
1369 ctx["result"] = raw_result;
1370 ctx["directives"] = nlohmann::json::array();
1371 }
1372 return ctx.dump();
1373}
1374
1386std::string ToolExecutor::build_pre_tool_json(
1387 const ToolCall& call,
1388 const std::string& tier,
1389 int iteration) {
1390 nlohmann::json j;
1391 j["tool_name"] = call.name;
1392 j["args"] = nlohmann::json::parse(serialize_args(call));
1393 j["tier"] = tier.empty() ? std::string{"lead"} : tier;
1394 j["iteration"] = iteration;
1395 return j.dump();
1396}
1397
1417static std::vector<std::string> extract_pipeline_stages(
1418 const nlohmann::json& result_json) {
1419 std::vector<std::string> stages;
1420 if (!result_json.contains("stages")) { return stages; }
1421 for (const auto& s : result_json["stages"]) {
1422 stages.push_back(s.get<std::string>());
1423 }
1424 return stages;
1425}
1426
1450static std::unique_ptr<Directive> build_complete_directive(
1451 const nlohmann::json& result_json) {
1452 auto cd = std::make_unique<CompleteDirective>(
1453 result_json.value("summary", ""));
1454 cd->coverage_gap = result_json.value("coverage_gap", false);
1455 cd->gap_description = result_json.value("gap_description", "");
1456 if (result_json.contains("suggested_files")
1457 && result_json["suggested_files"].is_array()) {
1458 cd->suggested_files =
1459 result_json["suggested_files"].get<std::vector<std::string>>();
1460 }
1461 return cd;
1462}
1463
1476static std::unique_ptr<Directive> build_directive(
1477 const nlohmann::json& d, const nlohmann::json& result_json) {
1478 auto type_str = d.value("type", "");
1479 std::unique_ptr<Directive> result;
1480 if (type_str == "stop_processing") {
1481 result = std::make_unique<StopProcessingDirective>();
1482 } else if (type_str == "delegate") {
1483 // gh#32 (v2.1.6): resume_delegation emits action=resume_delegation
1484 // with delegation_id but no target. The directive's target is
1485 // resolved later by the engine after loading the original
1486 // delegation's tier from storage.
1487 result = std::make_unique<DelegateDirective>(
1488 result_json.value("target", ""),
1489 result_json.value("task", ""),
1490 result_json.value("max_turns", -1),
1491 result_json.value("delegation_id", ""));
1492 } else if (type_str == "complete") {
1493 result = build_complete_directive(result_json);
1494 } else if (type_str == "pipeline") {
1495 result = std::make_unique<PipelineDirective>(
1496 extract_pipeline_stages(result_json),
1497 result_json.value("task", ""));
1498 }
1499 return result;
1500}
1501
1512static std::optional<std::pair<nlohmann::json, nlohmann::json>>
1513extract_directive_array(const std::string& raw_result) {
1514 auto resp = nlohmann::json::parse(raw_result, nullptr, false);
1515 if (!resp.is_object() || !resp.contains("directives")) {
1516 return std::nullopt;
1517 }
1518 auto dirs = resp["directives"];
1519 if (!dirs.is_array() || dirs.empty()) { return std::nullopt; }
1520 return std::make_pair(std::move(resp), std::move(dirs));
1521}
1522
1536void ToolExecutor::extract_and_process_directives(
1537 LoopContext& ctx, const std::string& raw_result) {
1538 if (hooks_.process_directives == nullptr) { return; }
1539 auto extracted = extract_directive_array(raw_result);
1540 if (!extracted) { return; }
1541 auto& [resp, dirs] = *extracted;
1542
1543 auto result_json = nlohmann::json::parse(
1544 resp.value("result", "{}"), nullptr, false);
1545
1546 std::vector<std::unique_ptr<Directive>> owned;
1547 for (const auto& d : dirs) {
1548 auto directive = build_directive(d, result_json);
1549 if (directive) { owned.push_back(std::move(directive)); }
1550 }
1551 if (owned.empty()) { return; }
1552
1553 std::vector<const Directive*> ptrs;
1554 ptrs.reserve(owned.size());
1555 for (const auto& d : owned) { ptrs.push_back(d.get()); }
1556 logger->info("Processing {} directives from tool result", ptrs.size());
1557 hooks_.process_directives(ctx, ptrs, hooks_.user_data);
1558}
1559
1560} // namespace entropic
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_permission_persist(const PermissionPersistInterface &persist)
Set permission persistence interface.
@ 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
double elapsed_ms(std::chrono::steady_clock::time_point start, std::chrono::steady_clock::time_point end)
Compute elapsed milliseconds between two time points.
Definition logging.h:210
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)
static std::string parse_tool_result_text(const std::string &result_json)
Extract the "result" text from an MCP result JSON envelope.
@ count
Sentinel — MUST remain last.
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 and type 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.
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 std::string check_required_fields(const nlohmann::json &schema, const nlohmann::json &args)
Check required fields are present.
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)
Mutable state carried through the agentic loop.
int consecutive_errors
Error streak counter.
int effective_max_tool_calls_per_turn
Per-identity override (-1 = LoopConfig, P3-18)
AgentState state
Current state.
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.