Entropic 2.14.3
Local-first agentic inference engine
Loading...
Searching...
No Matches
engine.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
16
17#include <nlohmann/json.hpp>
18
19#include <algorithm>
20#include <array>
21#include <chrono>
22#include <sstream>
23
24static auto logger = entropic::log::get("core.engine");
25
26namespace entropic {
27
28// ── Hook dispatch helpers ────────────────────────────────
29
38static void fire_hook_info(const HookInterface& hooks,
40 const char* json) {
41 if (hooks.fire_info != nullptr) {
42 hooks.fire_info(hooks.registry, point, json);
43 }
44}
45
53static void fire_loop_start_hook(const HookInterface& hooks,
54 const LoopContext& ctx) {
55 std::string json = "{\"message_count\":"
56 + std::to_string(ctx.messages.size())
57 + ",\"delegation_depth\":"
58 + std::to_string(ctx.delegation_depth) + "}";
59 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_START, json.c_str());
60}
61
69static void fire_loop_end_hook(const HookInterface& hooks,
70 const LoopContext& ctx) {
71 std::string json = "{\"final_state\":\""
72 + std::string(agent_state_name(ctx.state))
73 + "\",\"iterations\":"
74 + std::to_string(ctx.metrics.iterations) + "}";
75 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_END, json.c_str());
76}
77
85static void fire_loop_iteration_hook(const HookInterface& hooks,
86 const LoopContext& ctx) {
87 std::string json = "{\"iteration\":"
88 + std::to_string(ctx.metrics.iterations)
89 + ",\"state\":\"" + agent_state_name(ctx.state)
90 + "\",\"consecutive_errors\":"
91 + std::to_string(ctx.consecutive_errors) + "}";
93}
94
103 const LoopContext& ctx) {
104 std::string json = "{\"message_count\":"
105 + std::to_string(ctx.messages.size()) + "}";
107 json.c_str());
108}
109
110// Forward declaration
111static void remove_anchor_messages(LoopContext& ctx,
112 const std::string& key);
113
120static double now_seconds() {
121 auto now = std::chrono::steady_clock::now();
122 auto dur = now.time_since_epoch();
123 auto ms = std::chrono::duration_cast<
124 std::chrono::milliseconds>(dur);
125 return static_cast<double>(ms.count()) / 1000.0;
126}
127
137 const InferenceInterface& inference,
138 const LoopConfig& loop_config,
140 : inference_(inference),
141 loop_config_(loop_config),
142 token_counter_(loop_config.context_length),
143 compaction_manager_(compaction_config, token_counter_),
144 context_manager_(
145 compaction_manager_, callbacks_,
147 [this](LoopContext& ctx) { reinject_context_anchors(ctx); }
148 }),
149 response_generator_(
150 inference, loop_config, callbacks_,
151 GenerationEvents{&interrupt_flag_, &pause_flag_}) {
152 // gh#144 (v2.12.0): the default session must exist before any accessor
153 // runs. get_messages() and message_count() return a reference / a count
154 // from `conversations_.at("")`, and every pre-2.12.0 caller reaches them
155 // without ever naming a session — so the entry is created here, once, and
156 // never erased.
157 conversations_[""];
158 register_directive_handlers();
159}
160
167void AgentEngine::set_callbacks(const EngineCallbacks& callbacks) {
168 callbacks_ = callbacks;
169}
170
177void AgentEngine::set_tool_executor(
178 const ToolExecutionInterface& tool_exec) {
179 tool_exec_ = tool_exec;
180}
181
188void AgentEngine::set_tier_resolution(
189 const TierResolutionInterface& tier_res) {
190 tier_res_ = tier_res;
191}
192
199void AgentEngine::set_storage(const StorageInterface& storage) {
200 storage_ = storage;
201 compaction_manager_.set_storage(&storage_);
202}
203
210void AgentEngine::set_hooks(const HookInterface& hooks) {
211 hooks_ = hooks;
212 response_generator_.set_hooks(hooks);
213 context_manager_.set_hooks(hooks);
214 directive_processor_.set_hooks(hooks);
215}
216
228void AgentEngine::set_stream_observer(
229 TokenCallback observer, void* user_data) {
230 response_generator_.set_stream_observer(observer, user_data);
231}
232
241void AgentEngine::set_delegation_callbacks(
242 ent_decision_t (*on_start)(const ent_delegation_request_t*, void*),
243 ent_decision_t (*on_complete)(const ent_delegation_result_t*, void*),
244 void* user_data) {
245 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
246 delegation_cb_.start = on_start;
247 delegation_cb_.complete = on_complete;
248 delegation_cb_.user_data = user_data;
249}
250
258AgentEngine::delegation_callbacks_snapshot() const {
259 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
260 return delegation_cb_;
261}
262
270void AgentEngine::set_validation_provider(
271 char* (*provider)(void*), void* user_data) {
272 validation_provider_ = provider;
273 validation_provider_data_ = user_data;
274}
275
282const TierResolutionInterface& AgentEngine::tier_resolution() const {
283 return tier_res_;
284}
285
299void AgentEngine::apply_identity_overrides(LoopContext& ctx) {
300 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
301 return;
302 }
303 auto mi = tier_res_.get_tier_param(
304 ctx.locked_tier, "max_iterations", tier_res_.user_data);
305 if (!mi.empty()) {
306 ctx.effective_max_iterations = std::atoi(mi.c_str());
307 logger->info("[override] tier={} max_iterations={}",
309 }
310 auto mt = tier_res_.get_tier_param(
311 ctx.locked_tier, "max_tool_calls_per_turn", tier_res_.user_data);
312 if (!mt.empty()) {
313 ctx.effective_max_tool_calls_per_turn = std::atoi(mt.c_str());
314 logger->info("[override] tier={} max_tool_calls_per_turn={}",
316 }
317}
318
341int AgentEngine::resolve_max_iterations(const LoopContext& ctx) const {
342 int operator_limit = ctx.effective_max_iterations >= 0
343 ? ctx.effective_max_iterations
344 : loop_config_.max_iterations;
345 if (ctx.delegated_max_turns < 1) {
346 return operator_limit;
347 }
348 return std::min(operator_limit, ctx.delegated_max_turns);
349}
350
358int AgentEngine::resolve_max_tool_calls(const LoopContext& ctx) const {
359 return ctx.effective_max_tool_calls_per_turn >= 0
360 ? ctx.effective_max_tool_calls_per_turn
361 : loop_config_.max_tool_calls_per_turn;
362}
363
389void AgentEngine::begin_loop_preamble(LoopContext& ctx) {
390 apply_identity_overrides(ctx);
391 reinject_context_anchors(ctx);
392 if (ctx.metrics.start_time == 0.0) {
393 ctx.metrics.start_time = now_seconds();
394 }
395 set_state(ctx, AgentState::PLANNING);
396}
397
408void AgentEngine::run_loop(LoopContext& ctx, bool inherit_interrupt) {
409 // gh#158 (v2.13.0): publish THIS run's cancel token to THIS thread, so
410 // an external MCP tool call made from inside the loop aborts when its
411 // own session is interrupted and not when some other session is. The
412 // scope restores the previous token, so a child delegation loop nested
413 // inside a parent turn cannot orphan the parent's.
414 RunCancelScope cancel_scope(&run_cancel_flag());
415 // gh#81 (v2.4.3): child delegation loops inherit the parent's
416 // interrupt state — clearing it here would swallow a parent
417 // interrupt raised just before the child was dispatched. Only a
418 // fresh top-level turn resets.
419 if (!inherit_interrupt) {
420 reset_interrupt();
421 clear_pause_for_fresh_turn();
422 }
423 begin_loop_preamble(ctx); // gh#183: shared with run()
424 loop(ctx);
426 accumulate_per_tier(ctx);
427}
428
448void AgentEngine::clear_pause_for_fresh_turn() {
449 std::size_t others = 0;
450 {
451 std::lock_guard<std::mutex> guard(runs_mutex_);
452 others = active_runs_.size();
453 }
454 if (others <= 1) {
455 pause_flag_.store(false);
456 }
457}
458
471std::vector<Message> AgentEngine::run(std::vector<Message> messages,
472 const std::string& tier_override) {
473 // gh#35: update activity timestamp at run() entry so any host
474 // polling `seconds_since_last_activity` for an idle-exit policy
475 // sees a fresh value as soon as work begins.
476 last_activity_epoch_s_.store(
477 std::chrono::duration_cast<std::chrono::seconds>(
478 std::chrono::system_clock::now().time_since_epoch()).count());
479
480 // gh#158 (v2.13.0): see run_loop — this run's cancel token, published
481 // to this run's thread for the duration of the loop.
482 RunCancelScope cancel_scope(&run_cancel_flag());
483
484 LoopContext ctx;
485 ctx.messages = std::move(messages);
486 ctx.locked_tier = tier_override; // gh#99: "" routes; non-empty locks
487 // gh#144 (v2.12.0): carry the caller-scoped session down to the backend,
488 // which maps it to a KV sequence. "" is the default session.
489 ctx.session_key = active_session_key();
491
492 init_session_conversation(ctx);
493
494 // A fresh top-level turn always starts un-interrupted and un-paused;
495 // only a child loop inherits (gh#81). That asymmetry is why these two
496 // are NOT part of the shared preamble below.
497 reset_interrupt();
498 clear_pause_for_fresh_turn();
499
500 // gh#183 (v2.13.0): the same preamble run_loop runs — which is how a
501 // tier's identity overrides finally reach a run where that tier is the
502 // LEAD and not somebody's delegate. start_time is already set above, so
503 // the preamble leaves it alone.
504 begin_loop_preamble(ctx);
505
506 loop(ctx);
507
509 accumulate_run_metrics(ctx);
510 logger->info("Loop complete: {} iterations, {}ms",
512 return ctx.messages;
513}
514
521void AgentEngine::init_session_conversation(LoopContext& ctx) {
522 // gh#48 (v2.1.12): create the root conversation row when storage
523 // is wired so `ctx.conversation_id` carries a valid FK into
524 // `conversations(id)`. Every downstream delegation copies
525 // `parent_ctx.conversation_id` into `child_ctx.parent_conversation_id`
526 // (`delegation.cpp:387`); without this, the empty string propagates
527 // and every `INSERT INTO delegations` violates the FK silently,
528 // making `entropic.followup` return empty across an entire session.
529 if (storage_.create_conversation == nullptr) { return; }
530 std::string conv_id;
531 if (storage_.create_conversation("session", conv_id, storage_.user_data)
532 && !conv_id.empty()) {
533 ctx.conversation_id = std::move(conv_id);
534 } else {
535 logger->warn("Storage create_conversation failed at run() init; "
536 "delegations will not persist this session (gh#48)");
537 }
538}
539
549void AgentEngine::accumulate_run_metrics(LoopContext& ctx) {
550 {
551 std::lock_guard<std::mutex> guard(metrics_mutex_);
552 last_metrics_ = ctx.metrics; // P2-15: snapshot for entropic_status
553 }
554 accumulate_per_tier(ctx);
555}
556
563void AgentEngine::accumulate_per_tier(const LoopContext& ctx) {
564 std::lock_guard<std::mutex> guard(metrics_mutex_);
565 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
566 auto& tm = per_tier_metrics_[
567 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
568 tm.iterations += ctx.metrics.iterations;
569 tm.tool_calls += ctx.metrics.tool_calls;
570 tm.tokens_used += ctx.metrics.tokens_used;
571 tm.errors += ctx.metrics.errors;
572 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
573}
574
581LoopMetrics AgentEngine::last_loop_metrics() const {
582 std::lock_guard<std::mutex> guard(metrics_mutex_);
583 return last_metrics_;
584}
585
592std::unordered_map<std::string, LoopMetrics>
593AgentEngine::per_tier_metrics() const {
594 std::lock_guard<std::mutex> guard(metrics_mutex_);
595 return per_tier_metrics_;
596}
597
610static std::string last_substantive_assistant(const LoopContext& ctx) {
611 std::string found;
612 for (auto rit = ctx.messages.rbegin();
613 rit != ctx.messages.rend() && found.empty(); ++rit) {
614 if (rit->role == "assistant"
615 && rit->content.find_first_not_of(" \t\r\n")
616 != std::string::npos) {
617 found = rit->content;
618 }
619 }
620 return found;
621}
622
648static std::string annotate_hard_stop(const std::string& carried,
649 const std::string& reason,
650 const std::string& stop_noun) {
651 std::string out = "[" + reason
652 + " — no substantive output was produced before the "
653 + stop_noun + "]";
654 if (!carried.empty()) {
655 out = carried + "\n\n[" + reason
656 + " — the text above is the agent's last substantive output; "
657 "it did not signal completion]";
658 }
659 return out;
660}
661
679void AgentEngine::finish_with_carried_output(
680 LoopContext& ctx,
681 const std::string& reason,
682 const std::string& stop_noun,
683 const std::string& terminal_reason) {
684 std::string carried = last_substantive_assistant(ctx);
685 Message forced;
686 forced.role = "assistant";
687 forced.content = annotate_hard_stop(carried, reason, stop_noun);
688 ctx.messages.push_back(std::move(forced));
689 ctx.metadata["terminal_reason"] = terminal_reason;
690 ctx.metadata["cap_carried_content"] =
691 carried.empty() ? "false" : "true";
692 set_state(ctx, AgentState::COMPLETE);
693}
694
706void AgentEngine::force_iteration_cap_completion(LoopContext& ctx) {
707 finish_with_carried_output(
708 ctx,
709 "iteration cap reached after "
710 + std::to_string(ctx.metrics.iterations) + " iterations",
711 "cap",
712 "budget_exhausted");
713}
714
722void AgentEngine::loop(LoopContext& ctx) {
723 fire_loop_start_hook(hooks_, ctx); // ON_LOOP_START (v1.9.1)
724
725 while (!should_stop(ctx)) {
726 ctx.metrics.iterations++;
727
728 if (run_interrupted()) {
729 set_state(ctx, AgentState::INTERRUPTED);
730 break;
731 }
732
733 execute_iteration(ctx);
734
735 if (ctx.state == AgentState::ERROR) {
736 break;
737 }
738 }
739
740 if (ctx.metrics.iterations >= resolve_max_iterations(ctx)
741 && !is_terminal_state(ctx)) {
742 // P3-18 follow-up (2.0.6-rc16.2): force synthetic completion
743 // so the facade returns a proper result when the cap is hit.
744 // E7 (2.0.6-rc18): mark ctx.metadata with the terminal reason
745 // so delegate result and parent-tier relay can surface that
746 // the child did not complete naturally.
747 // gh#169 (v2.13.0): the cap ANNOTATES the agent's last
748 // substantive output instead of replacing it with a placeholder
749 // — the parent reads the last assistant message, and the child's
750 // real summary exists at cap time.
751 logger->warn("Loop ended due to max iterations ({}/{}) — "
752 "forcing synthetic entropic.complete",
753 ctx.metrics.iterations, resolve_max_iterations(ctx));
754 force_iteration_cap_completion(ctx);
755 }
756
757 fire_loop_end_hook(hooks_, ctx); // ON_LOOP_END (v1.9.1)
758}
759
767void AgentEngine::execute_iteration(LoopContext& ctx) {
768 logger->info("[LOOP] iter {}/{} state={} msgs={}",
769 ctx.metrics.iterations,
770 resolve_max_iterations(ctx),
771 agent_state_name(ctx.state),
772 ctx.messages.size());
773
774 fire_loop_iteration_hook(hooks_, ctx); // ON_LOOP_ITERATION (v1.9.1)
775
776 context_manager_.refresh_context_limit(ctx, 0);
777 context_manager_.prune_old_tool_results(ctx);
778 context_manager_.check_compaction(ctx);
779
780 fire_context_assemble_hook(hooks_, ctx); // ON_CONTEXT_ASSEMBLE (v1.9.1)
781
782 set_state(ctx, AgentState::EXECUTING);
783
784 // Hook: PRE_GENERATE — can cancel (v1.9.1)
785 if (fire_pre_hook(ENTROPIC_HOOK_PRE_GENERATE, ctx.metrics.iterations)) {
786 set_state(ctx, AgentState::COMPLETE);
787 return;
788 }
789
790 auto result = response_generator_.generate_response(ctx);
791 dispatch_post_generate(ctx, result);
792 process_generation_result(ctx, result);
793}
794
802void AgentEngine::process_generation_result(LoopContext& ctx,
803 GenerateResult& result) {
804 auto [cleaned, tool_calls] = parse_tool_calls(result.content);
805 logger->info("[ITER] finish={}, {} tool call(s), {} chars",
806 result.finish_reason, tool_calls.size(),
807 cleaned.size());
808 Message assistant_msg{"assistant", cleaned};
809 ctx.messages.push_back(std::move(assistant_msg));
810
811 bool made_tool_call =
812 !tool_calls.empty() && tool_exec_.process_tool_calls != nullptr;
813 // gh#84 (v2.5.1): the budget resets on genuine progress, not on a
814 // merely-parsed call — process_tool_results reports whether any
815 // tool actually executed (ok / ok_empty).
816 bool made_progress = false;
817 if (made_tool_call) {
818 made_progress = process_tool_results(ctx, tool_calls);
819 // gh#123: reset retry counter so a genuine tool call clears prior
820 // consecutive-empty-turn debt.
821 if (made_progress) {
822 ctx.metadata["zero_tool_call_retries"] = "0";
823 }
824 } else {
825 evaluate_no_tool_decision(ctx, cleaned, result.finish_reason);
826 }
827
828 // gh#80 (v2.5.0): charge the thinking budget before dispatch so a
829 // budget-triggered terminal is honored by dispatch_pending_or_halt.
830 charge_thinking_budget(ctx, result.content.size(), made_progress);
831
832 dispatch_pending_or_halt(ctx);
833}
834
840void AgentEngine::charge_thinking_budget(
841 LoopContext& ctx, size_t content_len, bool made_tool_call) {
842 if (loop_config_.budget_mode == BudgetMode::off) { return; }
843
844 // A tool call is productive action — reset the window and the
845 // nudge latch so the model earns a fresh thinking allowance.
846 if (made_tool_call) {
847 ctx.budget_tokens_since_tool = 0;
848 ctx.budget_window_start_s = 0.0;
849 ctx.budget_completion_nudged = false;
850 return;
851 }
852
853 int units = budget_units_consumed(ctx, content_len);
854 if (units < loop_config_.budget_limit) { return; }
855
856 if (!ctx.budget_completion_nudged) {
857 nudge_budget_completion(ctx);
858 } else {
859 hard_cut_budget(ctx);
860 }
861}
862
870int AgentEngine::budget_units_consumed(LoopContext& ctx, size_t content_len) {
871 if (loop_config_.budget_mode == BudgetMode::tokens) {
872 // Estimate generated tokens from content length — the
873 // function-pointer inference ABI doesn't thread an exact count
874 // back. ~4 chars/token; content includes <think> blocks, which
875 // is exactly the deliberation we want to charge.
876 constexpr size_t kCharsPerToken = 4;
877 ctx.budget_tokens_since_tool +=
878 static_cast<int>(content_len / kCharsPerToken);
879 return ctx.budget_tokens_since_tool;
880 }
881 // wall_clock
882 if (ctx.budget_window_start_s == 0.0) {
883 ctx.budget_window_start_s = now_seconds();
884 }
885 return static_cast<int>(now_seconds() - ctx.budget_window_start_s);
886}
887
893void AgentEngine::nudge_budget_completion(LoopContext& ctx) {
894 ctx.budget_completion_nudged = true;
895 ctx.budget_tokens_since_tool = 0;
896 ctx.budget_window_start_s =
897 (loop_config_.budget_mode == BudgetMode::wall_clock)
898 ? now_seconds() : 0.0;
899 logger->warn("[BUDGET] thinking budget reached ({} {}); nudging completion",
900 loop_config_.budget_limit,
901 loop_config_.budget_mode == BudgetMode::tokens
902 ? "tokens" : "seconds");
903 Message nudge{"user",
904 "[engine] thinking budget reached — emit entropic.complete now "
905 "with your best current answer. Further deliberation without a "
906 "tool call will be cut off."};
907 ctx.messages.push_back(std::move(nudge));
908}
909
928void AgentEngine::hard_cut_budget(LoopContext& ctx) {
929 logger->warn("[BUDGET] thinking budget exhausted after nudge — "
930 "hard-cutting turn");
931 finish_with_carried_output(
932 ctx,
933 "thinking budget exhausted after the completion nudge went "
934 "unheeded; no tool call was emitted",
935 "cut",
936 "budget_exhausted_thinking");
937}
938
957void AgentEngine::dispatch_pending_or_halt(LoopContext& ctx) {
958 if (run_interrupted() && !is_terminal_state(ctx)) {
959 logger->info("[ITER] interrupt observed during tool processing — "
960 "halting before pending dispatch");
961 set_state(ctx, AgentState::INTERRUPTED);
962 return;
963 }
964
965 if (is_terminal_state(ctx)) {
966 if (ctx.pending_delegation.has_value()
967 || ctx.pending_pipeline.has_value()) {
968 logger->info("[ITER] terminal state reached during batch "
969 "tool processing — dropping pending {}",
970 ctx.pending_delegation.has_value()
971 ? "delegation" : "pipeline");
972 }
973 return;
974 }
975
976 if (ctx.pending_delegation.has_value()) {
977 ctx.metrics.iterations--;
978 execute_pending_delegation(ctx);
979 } else if (ctx.pending_pipeline.has_value()) {
980 ctx.metrics.iterations--;
981 execute_pending_pipeline(ctx);
982 }
983}
984
993void AgentEngine::evaluate_no_tool_decision(
994 LoopContext& ctx,
995 const std::string& content,
996 const std::string& finish_reason) {
997 if (handle_terminal_finish_reasons(ctx, finish_reason)) { return; }
998 if (try_auto_chain(ctx, finish_reason, content)) {
999 logger->info("[DECISION] auto-chain triggered");
1000 return;
1001 }
1002 if (record_explicit_completion_failure(ctx, finish_reason)) { return; }
1003 if (response_generator_.is_response_complete(content, "[]")) {
1004 logger->info("[DECISION] response complete");
1005 set_state(ctx, AgentState::COMPLETE);
1006 }
1007}
1008
1031bool AgentEngine::handle_terminal_finish_reasons(
1032 LoopContext& ctx, const std::string& finish_reason) {
1033 bool handled = true;
1034 if (finish_reason == "interrupted") {
1035 logger->info("[DECISION] interrupted");
1036 set_state(ctx, AgentState::INTERRUPTED);
1037 } else if (finish_reason == "context_overflow") {
1038 ctx.metadata["failure_reason"] = "context_overflow";
1039 ctx.metadata["failure_tier"] = ctx.locked_tier;
1040 logger->error("[DECISION] prompt does not fit tier '{}' context — "
1041 "failing the turn instead of retrying it; the "
1042 "inference log carries the token breakdown",
1043 ctx.locked_tier);
1044 set_state(ctx, AgentState::ERROR);
1045 } else if (finish_reason == "length") {
1046 logger->info("[DECISION] length, continuing");
1047 } else {
1048 handled = false;
1049 }
1050 return handled;
1051}
1052
1063bool AgentEngine::record_explicit_completion_failure(
1064 LoopContext& ctx, const std::string& finish_reason) {
1065 if (!tier_requires_explicit_completion(ctx.locked_tier)) {
1066 return false;
1067 }
1068 auto& retries = ctx.metadata["zero_tool_call_retries"];
1069 int n = retries.empty() ? 0 : std::atoi(retries.c_str());
1070 // gh#123: default 3; per-tier override via max_consecutive_empty_turns
1071 int ceiling = 3;
1072 if (tier_res_.get_tier_param != nullptr) {
1073 auto cv = tier_res_.get_tier_param(
1074 ctx.locked_tier, "max_consecutive_empty_turns",
1075 tier_res_.user_data);
1076 if (!cv.empty()) { ceiling = std::atoi(cv.c_str()); }
1077 }
1078 if (n >= ceiling) {
1079 // Allowance exhausted — this is a genuine failure.
1080 ctx.metadata["failure_reason"] =
1081 "zero_tool_calls_with_explicit_completion";
1082 ctx.metadata["failure_tier"] = ctx.locked_tier;
1083 logger->error("[DECISION] empty-turn allowance exhausted "
1084 "(tier={}, n={}/{}), failing turn",
1085 ctx.locked_tier, n, ceiling);
1086 set_state(ctx, AgentState::ERROR);
1087 return true;
1088 }
1089 // Reasoning turn — nudge toward action, not a failure yet.
1090 logger->info("[DECISION] tier '{}' empty turn {}/{} "
1091 "(finish_reason={}), nudging toward action",
1092 ctx.locked_tier, n + 1, ceiling, finish_reason);
1093 std::string tools_hint =
1094 "(entropic.complete, entropic.delegate, or entropic.inspect)";
1095 if (tier_res_.get_tier_param != nullptr) {
1096 auto allowed = tier_res_.get_tier_param(
1097 ctx.locked_tier, "allowed_tools", tier_res_.user_data);
1098 if (!allowed.empty()) { tools_hint = "(" + allowed + ")"; }
1099 }
1100 Message correction;
1101 correction.role = "user";
1102 correction.content =
1103 "[SYSTEM] Your previous response contained no tool call. "
1104 "You must end every turn with exactly one tool call "
1105 + tools_hint + ". Retry.";
1106 ctx.messages.push_back(std::move(correction));
1107 retries = std::to_string(n + 1);
1108 set_state(ctx, AgentState::EXECUTING);
1109 return true;
1110}
1111
1124bool AgentEngine::tier_requires_explicit_completion(
1125 const std::string& tier) const {
1126 if (tier_res_.get_tier_param == nullptr) { return false; }
1127 auto val = tier_res_.get_tier_param(
1128 tier, "explicit_completion", tier_res_.user_data);
1129 return val == "true" || val == "1";
1130}
1131
1145bool AgentEngine::is_delegation_cycle(
1146 const LoopContext& ctx, const std::string& target) const {
1147 if (target == ctx.locked_tier) { return true; }
1148 for (const auto& anc : ctx.delegation_ancestor_tiers) {
1149 if (anc == target) { return true; }
1150 }
1151 return false;
1152}
1153
1161bool AgentEngine::is_delegation_repeat_blocked(
1162 const LoopContext& ctx, const std::string& target) const {
1163 return target == ctx.last_failed_delegation_target
1165 >= loop_config_.max_consecutive_failed_delegations;
1166}
1167
1176bool AgentEngine::fold_complete_into_assistant(
1177 LoopContext& ctx, const Message& tool_result_msg) const {
1178 auto tn_it = tool_result_msg.metadata.find("tool_name");
1179 bool is_complete = tn_it != tool_result_msg.metadata.end()
1180 && tn_it->second == "entropic.complete";
1181 if (!is_complete || ctx.messages.empty()) { return false; }
1182 auto& last = ctx.messages.back();
1183 auto sum_it = ctx.metadata.find("explicit_completion_summary");
1184 bool foldable = last.role == "assistant" && last.content.empty()
1185 && sum_it != ctx.metadata.end();
1186 if (!foldable) { return false; }
1187 last.content = sum_it->second;
1188 return true;
1189}
1190
1196int64_t AgentEngine::seconds_since_last_activity() const {
1197 auto last = last_activity_epoch_s_.load();
1198 if (last == 0) { return 0; }
1199 auto now = std::chrono::duration_cast<std::chrono::seconds>(
1200 std::chrono::system_clock::now().time_since_epoch()).count();
1201 return now > last ? (now - last) : 0;
1202}
1203
1211bool AgentEngine::should_stop(const LoopContext& ctx) const {
1212 bool at_limit = ctx.metrics.iterations >= resolve_max_iterations(ctx)
1214 return is_terminal_state(ctx) || at_limit;
1215}
1216
1231bool AgentEngine::is_terminal_state(const LoopContext& ctx) {
1232 return ctx.state == AgentState::COMPLETE
1233 || ctx.state == AgentState::ERROR
1234 || ctx.state == AgentState::INTERRUPTED;
1235}
1236
1245void AgentEngine::set_state(LoopContext& ctx, AgentState state) {
1246 auto prev = ctx.state;
1247 ctx.state = state;
1248 logger->info("State: {}", agent_state_name(state));
1249 if (callbacks_.on_state_change != nullptr) {
1250 callbacks_.on_state_change(
1251 static_cast<int>(state), callbacks_.user_data);
1252 }
1253 // Persistent slot (gh#40 fallout, v2.1.10): the streaming entry
1254 // points overwrite callbacks_ via set_callbacks(), so the legacy
1255 // on_state_change is silent for streaming runs. The persistent
1256 // slot is unaffected by that shuffle.
1257 if (state_observer_ != nullptr) {
1258 state_observer_(static_cast<int>(state), state_observer_data_);
1259 }
1260
1261 // Hook: ON_STATE_CHANGE (v1.9.1)
1262 {
1263 std::string json = "{\"previous\":\""
1264 + std::string(agent_state_name(prev))
1265 + "\",\"current\":\""
1266 + std::string(agent_state_name(state)) + "\"}";
1267 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_STATE_CHANGE, json.c_str());
1268 }
1269
1270 // Hook: ON_ERROR when entering ERROR state (v1.9.1)
1271 if (state == AgentState::ERROR) {
1272 std::string json = "{\"error_code\":\"STATE_ERROR\""
1273 ",\"iteration\":"
1274 + std::to_string(ctx.metrics.iterations)
1275 + ",\"consecutive\":" + std::to_string(ctx.consecutive_errors)
1276 + "}";
1277 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_ERROR, json.c_str());
1278 }
1279}
1280
1292void AgentEngine::interrupt() {
1293 // gh#158 (v2.13.0): `interrupt()` means EVERY run on this handle and
1294 // keeps that meaning. Each in-flight run carries its own token now, so
1295 // the handle-wide flag alone would not reach a run that started before
1296 // it was set and polls its own. Fan out explicitly.
1297 {
1298 std::lock_guard<std::mutex> guard(runs_mutex_);
1299 for (auto& [key, run] : active_runs_) {
1300 (void)key;
1301 run->interrupt.store(true, std::memory_order_release);
1302 }
1303 }
1304 if (!interrupt_flag_.exchange(true)) {
1305 logger->info("Engine interrupted");
1306 // P1-10: propagate to external MCP transports so in-flight
1307 // tool calls abort alongside the generation loop. This is the
1308 // handle-wide latch and stays handle-wide: `interrupt_session`
1309 // deliberately does NOT fire it (see its declaration).
1310 if (external_interrupt_cb_ != nullptr) {
1311 external_interrupt_cb_(external_interrupt_data_);
1312 }
1313 }
1314 pause_flag_.store(false);
1315}
1316
1328bool AgentEngine::run_interrupted() const {
1329 return interrupt_flag_.load() || current_run_cancelled();
1330}
1331
1344void AgentEngine::set_external_interrupt(void (*cb)(void*),
1345 void* user_data) {
1346 external_interrupt_cb_ = cb;
1347 external_interrupt_data_ = user_data;
1348}
1349
1362void AgentEngine::set_external_reset(void (*cb)(void*),
1363 void* user_data) {
1364 external_reset_cb_ = cb;
1365 external_reset_data_ = user_data;
1366}
1367
1373void AgentEngine::reset_interrupt() {
1374 interrupt_flag_.store(false);
1375 // gh#158: a run's own token is what its decode and its MCP transport
1376 // poll, so clearing only the handle flag would leave a re-used run
1377 // state latched — the same shape of bug gh#150 fixed one layer up.
1378 if (auto run = this_thread_run()) {
1379 run->interrupt.store(false, std::memory_order_release);
1380 }
1381 // gh#150: an interrupt is scoped to a RUN, not to the process. This
1382 // used to clear the engine's flag and stop, leaving every external
1383 // MCP transport latched shut with no path in the tree that could
1384 // reopen it — so the first Ctrl+C, or the first bridge client to
1385 // disconnect, silently disabled external tools until restart.
1386 if (external_reset_cb_ != nullptr) {
1387 external_reset_cb_(external_reset_data_);
1388 }
1389}
1390
1396void AgentEngine::pause() {
1397 logger->info("Engine paused");
1398 pause_flag_.store(true);
1399}
1400
1406void AgentEngine::cancel_pause() {
1407 logger->info("Pause cancelled, interrupting");
1408 pause_flag_.store(false);
1409 interrupt_flag_.store(true);
1410}
1411
1419std::pair<int, int> AgentEngine::context_usage(
1420 const std::vector<Message>& messages) const {
1421 int used = token_counter_.count_messages(messages);
1422 return {used, token_counter_.max_tokens};
1423}
1424
1439void AgentEngine::reinject_context_anchors(LoopContext& ctx) {
1440 std::unordered_map<std::string, std::string> snapshot;
1441 {
1442 std::lock_guard<std::mutex> guard(anchors_mutex_);
1443 snapshot = context_anchors_;
1444 }
1445 for (const auto& [key, content] : snapshot) {
1446 ContextAnchorDirective d(key, content);
1447 DirectiveResult r;
1448 dir_anchor(ctx, d, r);
1449 }
1450}
1451
1452// ── Directive handler registration ───────────────────────
1453
1459void AgentEngine::register_directive_handlers() {
1460 auto reg = [this](entropic_directive_type_t t, auto fn) {
1461 directive_processor_.register_handler(t,
1462 [this, fn](LoopContext& c, const Directive& d,
1463 DirectiveResult& r) {
1464 (this->*fn)(c, d, r);
1465 });
1466 };
1467 reg(ENTROPIC_DIRECTIVE_STOP_PROCESSING, &AgentEngine::dir_stop);
1468 reg(ENTROPIC_DIRECTIVE_TIER_CHANGE, &AgentEngine::dir_tier_change);
1469 reg(ENTROPIC_DIRECTIVE_DELEGATE, &AgentEngine::dir_delegate);
1470 reg(ENTROPIC_DIRECTIVE_PIPELINE, &AgentEngine::dir_pipeline);
1471 reg(ENTROPIC_DIRECTIVE_COMPLETE, &AgentEngine::dir_complete);
1472 reg(ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS,&AgentEngine::dir_clear_todos);
1473 reg(ENTROPIC_DIRECTIVE_INJECT_CONTEXT, &AgentEngine::dir_inject);
1474 reg(ENTROPIC_DIRECTIVE_PRUNE_MESSAGES, &AgentEngine::dir_prune);
1475 reg(ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR, &AgentEngine::dir_anchor);
1476 reg(ENTROPIC_DIRECTIVE_PHASE_CHANGE, &AgentEngine::dir_phase);
1477 reg(ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER,&AgentEngine::dir_notify);
1478}
1479
1480// ── Directive handlers ───────────────────────────────────
1481
1487void AgentEngine::dir_stop(
1488 LoopContext&, const Directive&, DirectiveResult& r) {
1489 logger->info("[DIRECTIVE] stop_processing");
1490 r.stop_processing = true;
1491}
1492
1498void AgentEngine::dir_tier_change(
1499 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1500 const auto& tc = static_cast<const TierChangeDirective&>(d);
1501 ctx.locked_tier = tc.tier;
1502 r.tier_changed = true;
1503 logger->info("[DIRECTIVE] tier_change: {}", tc.tier);
1504}
1505
1511void AgentEngine::dir_delegate(
1512 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1513 const auto& dl = static_cast<const DelegateDirective&>(d);
1514 ctx.pending_delegation = PendingDelegation{
1515 dl.target, dl.task, dl.max_turns,
1516 dl.resume_from_delegation_id,
1517 dl.context, // gh#162 (v2.13.0)
1518 dl.resume_by_target}; // gh#162 (v2.13.0)
1519 r.stop_processing = true;
1520 if (dl.resume_from_delegation_id.empty()) {
1521 logger->info("[DIRECTIVE] delegate: target={} task='{}'",
1522 dl.target, dl.task);
1523 } else {
1524 // gh#32 (v2.1.6): target will be resolved from storage later.
1525 logger->info("[DIRECTIVE] resume_delegation: id={} task='{}'",
1526 dl.resume_from_delegation_id, dl.task);
1527 }
1528}
1529
1535void AgentEngine::dir_pipeline(
1536 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1537 const auto& pl = static_cast<const PipelineDirective&>(d);
1538 ctx.pending_pipeline = PendingPipeline{pl.stages, pl.task, pl.context};
1539 r.stop_processing = true;
1540 logger->info("[DIRECTIVE] pipeline: {} stages", pl.stages.size());
1541}
1542
1554void AgentEngine::dir_complete(
1555 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1556 const auto& cd = static_cast<const CompleteDirective&>(d);
1557
1558 // P1-5 follow-up (2.0.6-rc16.2): signal validating phase so async
1559 // subscribers can progress queued/running/running:<tier> → validating.
1560 auto prior_state = ctx.state;
1561 set_state(ctx, AgentState::VERIFYING);
1562
1563 // Fire ON_COMPLETE pre-hook — application can validate/reject
1564 if (fire_complete_hook(cd.summary, ctx)) {
1565 logger->info("[DIRECTIVE] complete REJECTED by hook → revising");
1566 // Revision path: inform subscribers the validator is rewriting.
1567 ctx.metadata["validator_phase"] = "revising";
1568 set_state(ctx, prior_state);
1569 r.stop_processing = false; // don't stop — model should retry
1570 return;
1571 }
1572
1573 ctx.metadata["explicit_completion_summary"] = cd.summary;
1574 // Issue #10 (v2.1.4): persist coverage_gap signal in metadata so
1575 // delegation.cpp::build_child_result can hoist it onto the
1576 // DelegationResult that flows back to the parent. The metadata
1577 // path is the only channel that survives between the child loop
1578 // (which sees the directive) and the parent's
1579 // finalize_delegation_result (which reads the result).
1580 if (cd.coverage_gap) {
1581 ctx.metadata["coverage_gap"] = "true";
1582 ctx.metadata["gap_description"] = cd.gap_description;
1583 nlohmann::json files_arr = cd.suggested_files;
1584 ctx.metadata["suggested_files_json"] = files_arr.dump();
1585 }
1586 set_state(ctx, AgentState::COMPLETE);
1587 r.stop_processing = true;
1588 logger->info("[DIRECTIVE] complete coverage_gap={}",
1589 cd.coverage_gap);
1590}
1591
1597void AgentEngine::dir_clear_todos(
1598 LoopContext&, const Directive&, DirectiveResult&) {
1599 logger->debug("[DIRECTIVE] clear_self_todos (no-op)");
1600}
1601
1607void AgentEngine::dir_inject(
1608 LoopContext&, const Directive& d, DirectiveResult& r) {
1609 const auto& ic = static_cast<const InjectContextDirective&>(d);
1610 if (!ic.content.empty()) {
1611 Message msg;
1612 msg.role = ic.role;
1613 msg.content = ic.content;
1614 r.injected_messages.push_back(std::move(msg));
1615 logger->info("[DIRECTIVE] inject_context");
1616 }
1617}
1618
1624void AgentEngine::dir_prune(
1625 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1626 const auto& pm = static_cast<const PruneMessagesDirective&>(d);
1627 auto [pruned, freed] = context_manager_.prune_tool_results(
1628 ctx, pm.keep_recent);
1629 logger->info("[DIRECTIVE] prune: {} results, {} chars", pruned, freed);
1630}
1631
1643void AgentEngine::dir_anchor(
1644 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1645 const auto& ca = static_cast<const ContextAnchorDirective&>(d);
1646 if (ca.content.empty()) {
1647 {
1648 std::lock_guard<std::mutex> guard(anchors_mutex_);
1649 context_anchors_.erase(ca.key);
1650 }
1651 remove_anchor_messages(ctx, ca.key);
1652 logger->info("Removed anchor: {}", ca.key);
1653 return;
1654 }
1655 {
1656 std::lock_guard<std::mutex> guard(anchors_mutex_);
1657 context_anchors_[ca.key] = ca.content;
1658 }
1659 remove_anchor_messages(ctx, ca.key);
1660 Message anchor;
1661 anchor.role = "user";
1662 anchor.content = ca.content;
1663 anchor.metadata["is_context_anchor"] = "true";
1664 anchor.metadata["anchor_key"] = ca.key;
1665 ctx.messages.push_back(std::move(anchor));
1666 logger->info("Updated anchor: {}", ca.key);
1667}
1668
1674void AgentEngine::dir_phase(
1675 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1676 const auto& pc = static_cast<const PhaseChangeDirective&>(d);
1677 ctx.active_phase = pc.phase;
1678 logger->info("[DIRECTIVE] phase_change: {}", pc.phase);
1679}
1680
1686void AgentEngine::dir_notify(
1687 LoopContext&, const Directive& d, DirectiveResult&) {
1688 const auto& np = static_cast<const NotifyPresenterDirective&>(d);
1689 if (callbacks_.on_presenter_notify != nullptr) {
1690 callbacks_.on_presenter_notify(
1691 np.key.c_str(), np.data_json.c_str(),
1692 callbacks_.user_data);
1693 }
1694}
1695
1696// ── Tool call parsing (v1.8.5) ───────────────────────────
1697
1714 const nlohmann::json& obj, const std::string& tc_str) {
1715 ToolCall tc;
1716 tc.name = obj.value("name", "");
1717 tc.id = "tc-" + std::to_string(
1718 std::hash<std::string>{}(tc.name + tc_str) & 0xFFFF);
1719 if (obj.contains("arguments") && obj["arguments"].is_object()) {
1720 tc.arguments_json = obj["arguments"].dump();
1721 for (auto& [k, v] : obj["arguments"].items()) {
1722 tc.arguments[k] = v.is_string()
1723 ? v.get<std::string>() : v.dump();
1724 }
1725 }
1726 return tc;
1727}
1728
1736static std::vector<ToolCall> decode_tool_calls_json(
1737 const std::string& tc_str) {
1738 std::vector<ToolCall> calls;
1739 if (tc_str == "[]" || tc_str.empty()) { return calls; }
1740 auto arr = nlohmann::json::parse(tc_str, nullptr, false);
1741 if (!arr.is_array()) { return calls; }
1742 for (const auto& obj : arr) {
1743 calls.push_back(build_tool_call_from_json(obj, tc_str));
1744 }
1745 return calls;
1746}
1747
1761std::pair<std::string, std::vector<ToolCall>>
1762AgentEngine::parse_tool_calls(const std::string& raw_content) {
1763 if (inference_.parse_tool_calls == nullptr) {
1764 return {raw_content, {}};
1765 }
1766
1767 char* cleaned = nullptr;
1768 char* tc_json = nullptr;
1769 int rc = inference_.parse_tool_calls(
1770 raw_content.c_str(), &cleaned, &tc_json,
1771 inference_.adapter_data);
1772
1773 // gh#111 (v2.9.8): the tool-call parse callback is a SEPARATE inbound
1774 // boundary from response_generator's content sanitize. The backend derives
1775 // both `*cleaned` and `*tool_calls_json` from its own parse of the RAW
1776 // generation (common_chat / adapter parse_response), so a split multi-byte
1777 // UTF-8 codepoint — routine under MTP speculative decode — arrives here as
1778 // invalid bytes EVEN THOUGH result.content was already sanitized upstream
1779 // (response_generator.cpp:470). Both outputs cross into engine-owned state
1780 // at this one function:
1781 // cleaned_str -> assistant Message.content -> (delegation) extract_summary
1782 // fallback -> DelegationResult.summary
1783 // -> fire_delegate_complete_hook j.dump() [the gh#111 throw]
1784 // tc_str -> decode_tool_calls_json -> build_tool_call_from_json
1785 // obj["arguments"].dump() + CompleteTool result.dump()
1786 // v2.9.7 patched only the plugin-revised (out!=null) hook branches, which
1787 // never run on the headless path — so the raw summary still reached the
1788 // dump. Sanitize BOTH channels at this single boundary (the tool-call-channel
1789 // sibling of the content sanitize) so no downstream nlohmann::json::dump()
1790 // can throw type_error.316. (gh#3 recurrence.)
1791 std::string cleaned_str =
1792 mcp::sanitize_utf8(cleaned ? cleaned : raw_content);
1793 std::string tc_str = mcp::sanitize_utf8(tc_json ? tc_json : "[]");
1794
1795 if (inference_.free_fn != nullptr) {
1796 if (cleaned != nullptr) { inference_.free_fn(cleaned); }
1797 if (tc_json != nullptr) { inference_.free_fn(tc_json); }
1798 }
1799
1800 if (rc != 0) { return {cleaned_str, {}}; }
1801 auto calls = decode_tool_calls_json(tc_str);
1802 if (!calls.empty()) {
1803 logger->info("Parsed tool calls from model output");
1804 }
1805 return {cleaned_str, std::move(calls)};
1806}
1807
1813void AgentEngine::defang_meta_action_envelope(Message& msg) const {
1814 auto tn = msg.metadata.find("tool_name");
1815 bool is_meta = tn != msg.metadata.end()
1816 && tn->second.rfind("entropic.", 0) == 0;
1817 if (!is_meta) { return; }
1818 auto j = nlohmann::json::parse(msg.content, nullptr, false);
1819 bool is_envelope = j.is_object() && j.contains("action")
1820 && j["action"].is_string();
1821 if (!is_envelope) { return; }
1822 msg.content = "(engine: " + j["action"].get<std::string>()
1823 + " directive accepted)";
1824}
1825
1835bool AgentEngine::process_tool_results(
1836 LoopContext& ctx,
1837 const std::vector<ToolCall>& tool_calls) {
1838 set_state(ctx, AgentState::WAITING_TOOL);
1839
1840 auto results = tool_exec_.process_tool_calls(
1841 ctx, tool_calls, tool_exec_.user_data);
1842
1843 logger->info("[TOOLS] {} call(s) -> {} result message(s)",
1844 tool_calls.size(), results.size());
1845
1846 // gh#84 (v2.5.1): "progress" = at least one tool genuinely
1847 // executed (result_kind ok / ok_empty), not merely parsed. The
1848 // thinking budget resets only on progress so a duplicate/rejected
1849 // -spam spiral (rejected_duplicate, rejected_schema, anti-spiral)
1850 // can no longer keep its budget perpetually fresh.
1851 bool made_progress = false;
1852 for (auto& msg : results) {
1853 auto it = msg.metadata.find("result_kind");
1854 if (it != msg.metadata.end()
1855 && (it->second == "ok" || it->second == "ok_empty")) {
1856 made_progress = true;
1857 }
1858 if (fold_complete_into_assistant(ctx, msg)) {
1859 continue; // gh#68: folded; skip pushing JSON user message
1860 }
1861 defang_meta_action_envelope(msg); // gh#88: strip call-shaped envelope
1862 ctx.messages.push_back(std::move(msg));
1863 }
1864
1865 ctx.has_pending_tool_results = true;
1866 // Don't overwrite terminal states set by directive handlers
1867 if (!is_terminal_state(ctx)) {
1868 set_state(ctx, AgentState::EXECUTING);
1869 }
1870 return made_progress;
1871}
1872
1873// ── Anchor helper ────────────────────────────────────────
1874
1883 const std::string& key) {
1884 auto& msgs = ctx.messages;
1885 msgs.erase(
1886 std::remove_if(msgs.begin(), msgs.end(),
1887 [&key](const Message& m) {
1888 auto it = m.metadata.find("anchor_key");
1889 return it != m.metadata.end() && it->second == key;
1890 }),
1891 msgs.end());
1892}
1893
1894// ── Hook helpers (v1.9.1) ────────────────────────────────
1895
1905bool AgentEngine::fire_pre_hook(
1906 entropic_hook_point_t point, int iteration) {
1907 if (hooks_.fire_pre == nullptr) {
1908 return false;
1909 }
1910 std::string json = "{\"iteration\":"
1911 + std::to_string(iteration) + "}";
1912 char* modified = nullptr;
1913 int rc = hooks_.fire_pre(hooks_.registry,
1914 point, json.c_str(), &modified);
1915 free(modified);
1916 return rc != 0;
1917}
1918
1929static const std::array<const char*, 256>& json_escape_table() {
1930 static const auto tbl = []() {
1931 std::array<const char*, 256> t{};
1932 t[static_cast<unsigned char>('"')] = "\\\"";
1933 t[static_cast<unsigned char>('\\')] = "\\\\";
1934 t[static_cast<unsigned char>('\n')] = "\\n";
1935 t[static_cast<unsigned char>('\r')] = "\\r";
1936 t[static_cast<unsigned char>('\t')] = "\\t";
1937 return t;
1938 }();
1939 return tbl;
1940}
1941
1949static std::string json_escape_engine(const std::string& s) {
1950 const auto& tbl = json_escape_table();
1951 std::string out;
1952 out.reserve(s.size() + 16);
1953 for (char c : s) {
1954 const char* esc = tbl[static_cast<unsigned char>(c)];
1955 if (esc) { out += esc; }
1956 else { out += c; }
1957 }
1958 return out;
1959}
1960
1972static std::string build_tool_manifest(
1973 const std::vector<Message>& messages) {
1974 std::string manifest;
1975 for (const auto& msg : messages) {
1976 auto it = msg.metadata.find("tool_name");
1977 if (it == msg.metadata.end() || it->second.empty()) {
1978 continue;
1979 }
1980 // Issue #5 (v2.1.3): when prune has stubbed this message, the
1981 // original (pre-stub) content size is preserved in
1982 // metadata["original_content"]. Report the real size so the
1983 // validator sees accurate evidence sizes instead of the stub
1984 // length (which is always ~50 chars regardless of original).
1985 auto orig = msg.metadata.find("original_content");
1986 size_t real_size = (orig != msg.metadata.end())
1987 ? orig->second.size()
1988 : msg.content.size();
1989 manifest += "- " + it->second
1990 + " \xe2\x86\x92 " + std::to_string(real_size)
1991 + " chars\n";
1992 }
1993 return manifest;
1994}
1995
2004static std::string format_tool_evidence_entry(const Message& msg,
2005 size_t chars_per_result) {
2006 auto orig = msg.metadata.find("original_content");
2007 const std::string& src = (orig != msg.metadata.end())
2008 ? orig->second
2009 : msg.content;
2010 auto iter = msg.metadata.find("added_at_iteration");
2011 std::string out = "## ";
2012 out += msg.metadata.at("tool_name");
2013 if (iter != msg.metadata.end()) {
2014 out += " [iter " + iter->second + "]";
2015 }
2016 out += "\n";
2017 if (src.size() <= chars_per_result) {
2018 out += src;
2019 } else {
2020 out += src.substr(0, chars_per_result);
2021 out += "\n[... truncated, "
2022 + std::to_string(src.size() - chars_per_result)
2023 + " more chars]";
2024 }
2025 out += "\n\n";
2026 return out;
2027}
2028
2056static std::string build_tool_evidence(
2057 const std::vector<Message>& messages,
2058 size_t max_results,
2059 size_t chars_per_result) {
2060 std::vector<const Message*> tool_msgs;
2061 for (const auto& msg : messages) {
2062 auto it = msg.metadata.find("tool_name");
2063 if (it == msg.metadata.end() || it->second.empty()) {
2064 continue;
2065 }
2066 tool_msgs.push_back(&msg);
2067 }
2068 if (tool_msgs.empty()) { return {}; }
2069
2070 size_t elided = (tool_msgs.size() > max_results)
2071 ? (tool_msgs.size() - max_results) : 0;
2072 size_t start = elided;
2073 std::string out;
2074 if (elided > 0) {
2075 out += "(" + std::to_string(elided)
2076 + " earlier tool results elided for length)\n";
2077 }
2078 for (size_t i = start; i < tool_msgs.size(); ++i) {
2079 out += format_tool_evidence_entry(*tool_msgs[i], chars_per_result);
2080 }
2081 return out;
2082}
2083
2099 const std::string& base,
2100 const ToolExecutionInterface& tool_exec) {
2101 if (tool_exec.history_json == nullptr) { return base; }
2102 char* js = tool_exec.history_json(20, tool_exec.user_data);
2103 if (js == nullptr) { return base; }
2104 std::string out = base + "prior-iteration history: " + js + "\n";
2105 if (tool_exec.free_fn != nullptr) { tool_exec.free_fn(js); }
2106 return out;
2107}
2108
2116static std::string extract_system_prompt(
2117 const std::vector<Message>& messages) {
2118 for (const auto& msg : messages) {
2119 if (msg.role == "system") { return msg.content; }
2120 }
2121 return {};
2122}
2123
2139void AgentEngine::fire_post_generate_hook(
2140 GenerateResult& result,
2141 const std::string& tier,
2142 const std::vector<Message>& messages) {
2143 if (hooks_.fire_post == nullptr) {
2144 return;
2145 }
2146 auto manifest = build_tool_manifest(messages);
2147 manifest = enrich_manifest_with_history(manifest, tool_exec_);
2148 auto sys = extract_system_prompt(messages);
2149 // Issue #5 (v2.1.3): include un-pruned tool-result evidence so the
2150 // constitutional validator can verify file:line citations against
2151 // actual content rather than the stubs that prune leaves behind.
2152 // Bounded at 20 most-recent results × 800 chars each (~16KB max
2153 // evidence), well within any sane critique-prompt budget. The
2154 // field is OPTIONAL on the hook contract — pre-2.1.3 consumers
2155 // ignore it; v2.1.3 validator augments its critique prompt.
2156 auto evidence = build_tool_evidence(messages, 20, 800);
2157 std::string json =
2158 "{\"finish_reason\":\"" + result.finish_reason
2159 + "\",\"content\":\"" + json_escape_engine(result.content)
2160 + "\",\"tier\":\"" + tier
2161 + "\",\"tool_context\":\"" + json_escape_engine(manifest)
2162 + "\",\"tool_evidence\":\"" + json_escape_engine(evidence)
2163 + "\",\"system_prompt\":\"" + json_escape_engine(sys) + "\"}";
2164 char* out = nullptr;
2165 hooks_.fire_post(hooks_.registry,
2166 ENTROPIC_HOOK_POST_GENERATE, json.c_str(), &out);
2167 if (out != nullptr) {
2168 // gh#3 recurrence (v2.9.6): a hook's revised content is an
2169 // inbound boundary crossing a plugin .so, same class as the
2170 // MCP tool-result boundary — sanitize before it re-enters the
2171 // engine so downstream json::dump() (delegate-complete hook,
2172 // facade serialization, storage) cannot throw type_error 316.
2173 result.content = mcp::sanitize_utf8(out);
2174 logger->info("POST_GENERATE hook revised content");
2175 free(out);
2176 }
2177}
2178
2200void AgentEngine::dispatch_post_generate(
2201 LoopContext& ctx, GenerateResult& result) {
2202 // The per-turn reminders built from pending_validation_feedback
2203 // and pending_anti_spiral_warning were just consumed by
2204 // generate_response — clear both so neither leaks into a later
2205 // iteration. (#42, #45 — both one-shot.)
2206 ctx.pending_validation_feedback.clear();
2207 ctx.pending_anti_spiral_warning.clear();
2208 fire_post_generate_hook(result, ctx.locked_tier, ctx.messages);
2209 // After POST_GENERATE the validator may have rejected — stash
2210 // its reason for the NEXT iteration's system prompt.
2211 capture_validation_feedback(ctx);
2212}
2213
2219void AgentEngine::capture_validation_feedback(LoopContext& ctx) {
2220 if (validation_provider_ == nullptr) { return; }
2221 char* v = validation_provider_(validation_provider_data_);
2222 if (v == nullptr) { return; }
2223 std::string raw(v);
2224 free(v);
2225 try {
2226 auto j = nlohmann::json::parse(raw);
2227 auto verdict = j.value("verdict", "");
2228 if (verdict.rfind("rejected", 0) != 0) { return; }
2229 std::string joined;
2230 if (j.contains("violations") && j["violations"].is_array()) {
2231 for (const auto& vio : j["violations"]) {
2232 if (!joined.empty()) { joined += "; "; }
2233 joined += vio.is_string()
2234 ? vio.get<std::string>()
2235 : vio.dump();
2236 }
2237 }
2238 if (joined.empty()) { joined = verdict; }
2239 ctx.pending_validation_feedback = joined;
2240 } catch (const nlohmann::json::exception&) {
2241 // Malformed provider output is non-fatal; just skip.
2242 }
2243}
2244
2256static std::string build_tool_results_json(
2257 const std::vector<Message>& messages) {
2258 std::string arr = "[";
2259 bool first = true;
2260 for (const auto& msg : messages) {
2261 auto it = msg.metadata.find("tool_name");
2262 if (it == msg.metadata.end() || it->second.empty()) {
2263 continue;
2264 }
2265 if (!first) { arr += ","; }
2266 arr += "{\"name\":\"" + json_escape_engine(it->second)
2267 + "\",\"content\":\"" + json_escape_engine(msg.content)
2268 + "\"}";
2269 first = false;
2270 }
2271 arr += "]";
2272 return arr;
2273}
2274
2295bool AgentEngine::fire_complete_hook(
2296 const std::string& summary,
2297 const LoopContext& ctx) {
2298 if (hooks_.fire_pre == nullptr) { return false; }
2299
2300 auto tool_results = build_tool_results_json(ctx.messages);
2301 // E3 (2.0.6-rc17): splice validator verdict/violations so
2302 // ON_COMPLETE consumers can distinguish clean pass from
2303 // reverted-for-length / max-revisions-exhausted.
2304 std::string validation_block = ",\"validation\":null";
2305 if (validation_provider_ != nullptr) {
2306 char* v = validation_provider_(validation_provider_data_);
2307 if (v != nullptr) {
2308 validation_block = ",\"validation\":" + std::string(v);
2309 free(v);
2310 }
2311 }
2312 std::string json =
2313 "{\"summary\":\"" + json_escape_engine(summary)
2314 + "\",\"tier\":\"" + ctx.locked_tier
2315 + "\",\"tool_results\":" + tool_results
2316 + ",\"iteration\":" + std::to_string(ctx.metrics.iterations)
2317 + validation_block
2318 + "}";
2319 char* modified = nullptr;
2320 int rc = hooks_.fire_pre(hooks_.registry,
2321 ENTROPIC_HOOK_ON_COMPLETE, json.c_str(), &modified);
2322 if (modified != nullptr) {
2323 // Hook provided rejection feedback — inject as user message.
2324 // Same inbound-boundary gap as fire_post_generate_hook above:
2325 // sanitize the hook's returned bytes before they enter a
2326 // Message that later flows to json::dump() call sites.
2327 Message feedback;
2328 feedback.role = "user";
2329 feedback.content =
2330 std::string("[CITATION VALIDATION] ") + mcp::sanitize_utf8(modified);
2331 const_cast<LoopContext&>(ctx).messages.push_back(
2332 std::move(feedback));
2333 free(modified);
2334 }
2335 return rc != 0;
2336}
2337
2347bool AgentEngine::fire_delegate_pre_hook(
2348 const PendingDelegation& pending, int depth) {
2349 if (hooks_.fire_pre == nullptr) {
2350 return false;
2351 }
2352 std::string json = "{\"target_tier\":\""
2353 + pending.target + "\",\"task\":\""
2354 + pending.task + "\",\"depth\":"
2355 + std::to_string(depth) + "}";
2356 char* modified = nullptr;
2357 int rc = hooks_.fire_pre(hooks_.registry,
2358 ENTROPIC_HOOK_ON_DELEGATE, json.c_str(), &modified);
2359 free(modified);
2360 return rc != 0;
2361}
2362
2363// ── detail::build_delegate_complete_json (gh#113, v2.9.9) ──────────────────
2364// Extracted from fire_delegate_complete_hook so it can be driven directly in
2365// unit tests with a bad-UTF8 summary (simulating gh#112 storage-load path).
2366
2382 const std::string& target,
2383 bool success,
2384 const std::string& summary) {
2385 nlohmann::json j;
2386 j["target_tier"] = target;
2387 j["success"] = success;
2388 j["result_kind"] = result_kind_to_string(success
2389 ? ToolResultKind::ok
2391 j["summary"] = mcp::sanitize_utf8(summary);
2392 return j.dump();
2393}
2394
2410void AgentEngine::fire_delegate_complete_hook(
2411 const std::string& target, bool success,
2412 const std::string& summary) {
2413 if (hooks_.fire_post == nullptr) {
2414 return;
2415 }
2416 std::string json = detail::build_delegate_complete_json(
2417 target, success, summary);
2418 char* out = nullptr;
2419 hooks_.fire_post(hooks_.registry,
2420 ENTROPIC_HOOK_ON_DELEGATE_COMPLETE, json.c_str(), &out);
2421 free(out);
2422}
2423
2424// ── Delegation execution (v1.8.6) ────────────────────────
2425
2433static void run_child_loop_trampoline(LoopContext& ctx, void* user_data) {
2434 auto* engine = static_cast<AgentEngine*>(user_data);
2435 // gh#81 (v2.4.3): inherit the parent's interrupt — a child loop
2436 // must not clear a parent interrupt raised at/just-before dispatch.
2437 engine->run_loop(ctx, /*inherit_interrupt=*/true);
2438}
2439
2447 Message reject;
2448 reject.role = "user";
2449 reject.content = "[DELEGATION REJECTED] Maximum delegation "
2450 "depth (" + std::to_string(
2451 AgentEngine::MAX_DELEGATION_DEPTH) +
2452 ") reached.";
2453 ctx.messages.push_back(std::move(reject));
2454}
2455
2467 LoopContext& ctx, const std::string& target) {
2468 std::string chain;
2469 for (const auto& anc : ctx.delegation_ancestor_tiers) {
2470 chain += anc + " -> ";
2471 }
2472 chain += ctx.locked_tier + " -> " + target;
2473
2474 Message reject;
2475 reject.role = "user";
2476 reject.content = "[DELEGATION REJECTED] Circular delegation "
2477 "detected: tier '" + target +
2478 "' already in the active delegation chain "
2479 "(" + chain + "). Choose a different target.";
2480 ctx.metadata["failure_reason"] = "delegation_cycle";
2481 ctx.metadata["failure_target"] = target;
2482 ctx.messages.push_back(std::move(reject));
2483}
2484
2494 const std::string& target, const DelegationResult& result) {
2495 std::string tag = result.success ? "COMPLETE" : "FAILED";
2496 Message msg;
2497 msg.role = "user";
2498 msg.content = "[DELEGATION " + tag + ": " + target + "] " + result.summary;
2499 ctx.messages.push_back(std::move(msg));
2500}
2501
2514 LoopContext& ctx, const std::string& target, int n) {
2515 Message reject;
2516 reject.role = "user";
2517 reject.content = "[DELEGATION REJECTED] '" + target
2518 + "' has just failed " + std::to_string(n)
2519 + " times in a row. Stop retrying this target. Either "
2520 "respond to the user with what you have, or delegate to "
2521 "a different tier.";
2522 ctx.metadata["failure_reason"] = "delegation_repeat_blocked";
2523 ctx.metadata["failure_target"] = target;
2524 ctx.messages.push_back(std::move(reject));
2525}
2526
2545 LoopContext& ctx, const std::string& target,
2546 const std::vector<std::string>& tools) {
2547 std::string names;
2548 for (const auto& t : tools) {
2549 if (!names.empty()) { names += ", "; }
2550 names += t;
2551 }
2552 Message reject;
2553 reject.role = "user";
2554 reject.content =
2555 "[DELEGATION REJECTED] delegation.isolation is 'sandbox', but '"
2556 + target + "' can reach external MCP tools that are not declared "
2557 "read-only (" + names + "). An external server runs in its own "
2558 "process and cannot be moved into the sandbox, so its writes "
2559 "would escape it. Either restrict the tier's allowed_tools to "
2560 "read-only tools, have the server declare readOnlyHint, or set "
2561 "delegation.isolation: none.";
2562 ctx.metadata["failure_reason"] = "delegation_isolation_unsafe";
2563 ctx.metadata["failure_target"] = target;
2564 ctx.messages.push_back(std::move(reject));
2565}
2566
2575bool AgentEngine::reject_delegation_if_guarded(
2576 LoopContext& ctx, const PendingDelegation& pending) {
2577 bool rejected = true;
2578 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2579 logger->warn("Delegation rejected: depth {} >= max {}",
2580 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2581 push_delegation_rejected(ctx);
2582 } else if (is_delegation_cycle(ctx, pending.target)) {
2583 // P1-9: reject A→B→A and longer cycles before running a child.
2584 logger->warn("Delegation rejected: cycle on target tier '{}'",
2585 pending.target);
2586 push_delegation_cycle_rejected(ctx, pending.target);
2587 } else if (auto unsafe = isolation_unsafe_tools(ctx, pending.target);
2588 !unsafe.empty()) {
2589 // gh#160: refuse rather than silently run uncontained.
2590 logger->error("Delegation rejected: isolation is on but '{}' can "
2591 "reach {} non-read-only external tool(s)",
2592 pending.target, unsafe.size());
2593 push_delegation_isolation_unsafe(ctx, pending.target, unsafe);
2594 } else if (is_delegation_repeat_blocked(ctx, pending.target)) {
2595 // gh#64: refuse re-delegation to a target that has just failed
2596 // N times in a row. Pre-fix, a lead retrying a Q4 specialist
2597 // that couldn't emit a tool call would burn 30+ identical
2598 // delegations before its own iteration cap stopped it.
2599 logger->warn("Delegation rejected: '{}' failed {}x in a row "
2600 "(>= max_consecutive_failed_delegations={})",
2601 pending.target,
2602 ctx.consecutive_failed_delegations,
2603 loop_config_.max_consecutive_failed_delegations);
2605 ctx, pending.target, ctx.consecutive_failed_delegations);
2606 } else {
2607 rejected = false;
2608 }
2609 return rejected;
2610}
2611
2625void AgentEngine::execute_pending_delegation(LoopContext& ctx) {
2626 auto pending = std::move(*ctx.pending_delegation);
2627 ctx.pending_delegation.reset();
2628
2629 if (reject_delegation_if_guarded(ctx, pending)) { return; }
2630
2631 set_state(ctx, AgentState::DELEGATING);
2632
2633 // gh#32 (v2.1.6): resume_delegation tool emits a directive with
2634 // resume_from_delegation_id set; the target tier is unknown to the
2635 // tool and must be resolved from storage. Failure to load (unknown
2636 // id, no storage) surfaces a typed error to the lead.
2637 //
2638 // The pre-hook gate is interleaved with resume so both share a
2639 // single early-exit (knots returns gate).
2640 std::vector<Message> resume_history;
2641 bool blocked = false;
2642 // gh#162 (v2.13.0): `resume_by_target` is the second way in — the id is
2643 // empty precisely because the lead did not have to look it up.
2644 const bool is_resume = !pending.resume_from_delegation_id.empty()
2645 || pending.resume_by_target;
2646 if (is_resume
2647 && !resolve_resume_delegation(ctx, pending, resume_history)) {
2648 blocked = true;
2649 } else if (fire_delegate_pre_hook(pending, ctx.delegation_depth)) {
2650 logger->info("ON_DELEGATE hook cancelled delegation");
2651 blocked = true;
2652 }
2653 if (blocked) {
2654 set_state(ctx, AgentState::EXECUTING);
2655 return;
2656 }
2657
2658 fire_delegation_start(ctx, pending.target, pending.task);
2659 auto result = run_pending_delegation(
2660 ctx, pending, std::move(resume_history));
2661 push_delegation_result(ctx, pending.target, result);
2662
2663 // gh#64: track consecutive failures against the same target.
2664 if (result.success) {
2665 ctx.last_failed_delegation_target.clear();
2666 ctx.consecutive_failed_delegations = 0;
2667 } else if (pending.target == ctx.last_failed_delegation_target) {
2668 ++ctx.consecutive_failed_delegations;
2669 } else {
2670 ctx.last_failed_delegation_target = pending.target;
2671 ctx.consecutive_failed_delegations = 1;
2672 }
2673
2674 fire_delegation_complete(ctx, pending.target, result);
2675 fire_delegate_complete_hook(pending.target, result.success,
2676 result.summary);
2677
2678 finalize_delegation_result(ctx, result);
2679}
2680
2693void AgentEngine::relay_partial_result(
2694 LoopContext& ctx, const std::string& summary) {
2695 GenerateResult relay_result;
2696 relay_result.content = summary;
2697 relay_result.finish_reason = "stop";
2698 relay_result.tool_calls_json = "[]";
2699 fire_post_generate_hook(
2700 relay_result, ctx.locked_tier, ctx.messages);
2701 ctx.metadata["explicit_completion_summary"] = relay_result.content;
2702 set_state(ctx, AgentState::COMPLETE);
2703}
2704
2713 const std::string& tier, const DelegationResult& result) {
2714 std::string body =
2715 "[COVERAGE GAP from " + tier + "]\n"
2716 "Summary so far: " + result.summary + "\n"
2717 "What's missing: " + result.gap_description;
2718 if (!result.suggested_files.empty()) {
2719 body += "\nSuggested files to inspect:";
2720 for (const auto& f : result.suggested_files) {
2721 body += "\n - " + f;
2722 }
2723 }
2724 return body;
2725}
2726
2743 LoopContext& ctx, const std::string& summary) {
2744 for (auto rit = ctx.messages.rbegin();
2745 rit != ctx.messages.rend(); ++rit) {
2746 if (rit->role == "assistant" && rit->content.empty()) {
2747 rit->content = summary;
2748 break;
2749 }
2750 }
2751}
2752
2773void AgentEngine::finalize_delegation_result(
2774 LoopContext& ctx, const DelegationResult& result) {
2775 // delegation.cpp sets success=false when terminal_reason is non-empty,
2776 // so the two relay branches are disjoint — check them independently.
2777 const bool in_relay_tier =
2778 relay_single_delegate_tiers_.count(ctx.locked_tier) > 0;
2779 if (in_relay_tier && result.coverage_gap) {
2780 Message gap;
2781 gap.role = "user";
2782 gap.content = build_coverage_gap_message(
2783 result.target_tier, result);
2784 ctx.messages.push_back(std::move(gap));
2785 ctx.metadata["relay_status"] = "coverage_gap_suppressed";
2786 logger->info(
2787 "[COVERAGE GAP] suppressing auto-relay for tier={} "
2788 "({} suggested files)",
2789 result.target_tier, result.suggested_files.size());
2790 set_state(ctx, AgentState::EXECUTING);
2791 return;
2792 }
2793 if (result.success && in_relay_tier) {
2794 relay_partial_result(ctx, result.summary);
2795 log_relay_status(ctx);
2796 return;
2797 }
2798 if (!result.terminal_reason.empty() && in_relay_tier) {
2799 relay_partial_result(ctx,
2800 "[partial — budget_exhausted] " + result.summary);
2801 log_relay_status(ctx, result.terminal_reason);
2802 return;
2803 }
2804 bool needs_explicit = tier_requires_explicit_completion(
2805 ctx.locked_tier);
2806 if (result.success && !needs_explicit) {
2807 fold_delegation_summary(ctx, result.summary); // gh#119
2808 }
2809 set_state(ctx, (result.success && !needs_explicit)
2810 ? AgentState::COMPLETE : AgentState::EXECUTING);
2811}
2812
2827void AgentEngine::log_relay_status(LoopContext& ctx,
2828 const std::string& terminal_reason) {
2829 std::string verdict;
2830 if (validation_provider_ != nullptr) {
2831 char* v = validation_provider_(validation_provider_data_);
2832 if (v != nullptr) {
2833 std::string jv(v);
2834 free(v);
2835 auto pos = jv.find("\"verdict\":\"");
2836 if (pos != std::string::npos) {
2837 pos += 11;
2838 auto end = jv.find('"', pos);
2839 if (end != std::string::npos) {
2840 verdict = jv.substr(pos, end - pos);
2841 }
2842 }
2843 }
2844 }
2845 if (!terminal_reason.empty()) {
2846 ctx.metadata["relay_status"] = "budget_exhausted_relayed";
2847 logger->warn(
2848 "Relay: single-delegate result used "
2849 "(partial — terminal_reason={}, verdict={})",
2850 terminal_reason, verdict.empty() ? "none" : verdict);
2851 return;
2852 }
2853 if (verdict == "skipped" || verdict.empty()) {
2854 ctx.metadata["relay_status"] = "validation_skipped";
2855 logger->info(
2856 "Relay: single-delegate result used "
2857 "(lead validation skipped per config)");
2858 } else {
2859 ctx.metadata["relay_status"] = "validated";
2860 logger->info(
2861 "Relay: single-delegate result used "
2862 "(passed lead validation)");
2863 }
2864}
2865
2880bool AgentEngine::reject_pipeline_if_guarded(
2881 LoopContext& ctx, const PendingPipeline& pending) {
2882 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2883 logger->warn("Pipeline rejected: depth {} >= max {}",
2884 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2885 Message reject;
2886 reject.role = "user";
2887 reject.content = "[PIPELINE REJECTED] Maximum delegation "
2888 "depth reached.";
2889 ctx.messages.push_back(std::move(reject));
2890 return true;
2891 }
2892 for (const auto& stage : pending.stages) {
2893 auto unsafe = isolation_unsafe_tools(ctx, stage);
2894 if (!unsafe.empty()) {
2895 logger->error("Pipeline rejected: isolation is on but stage "
2896 "'{}' can reach {} non-read-only external "
2897 "tool(s)", stage, unsafe.size());
2898 push_delegation_isolation_unsafe(ctx, stage, unsafe);
2899 return true;
2900 }
2901 }
2902 return false;
2903}
2904
2912void AgentEngine::execute_pending_pipeline(LoopContext& ctx) {
2913 auto pending = std::move(*ctx.pending_pipeline);
2914 ctx.pending_pipeline.reset();
2915
2916 if (reject_pipeline_if_guarded(ctx, pending)) { return; }
2917
2918 set_state(ctx, AgentState::DELEGATING);
2919
2920 // gh#33 (v2.1.6): engine-scoped sandbox; non-owning pointer.
2921 // gh#160 (v2.13.0): both the root and the sandbox are per SESSION now,
2922 // and the dir-swap callback is finally passed on — without it every
2923 // ScopedSandbox took the non-sandboxed branch.
2924 auto root = resolve_session_root(ctx.session_key);
2925 DelegationManager mgr(run_child_loop_trampoline, this,
2926 tier_res_, root,
2927 sandbox_for_session(ctx.session_key));
2928 auto swap = dir_swap_snapshot();
2929 mgr.set_dir_swap(swap.first, swap.second);
2930 if (storage_.create_delegation != nullptr) {
2931 mgr.set_storage(&storage_);
2932 }
2933 // gh#29 (v2.1.5): snapshot under the mutex (see execute_pending_
2934 // delegation comment); avoids a torn read against a concurrent
2935 // set_delegation_callbacks call.
2936 auto cb_snap = delegation_callbacks_snapshot();
2937 mgr.set_delegation_callbacks(
2938 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2939 std::vector<DelegationResult> stage_log;
2940 auto result = mgr.execute_pipeline(
2941 ctx, pending.stages, pending.task, stage_log, pending.context);
2942
2943 std::string tag = result.success ? "COMPLETE" : "FAILED";
2944 std::string content = "[PIPELINE " + tag + "]";
2945 for (size_t i = 0; i < stage_log.size(); ++i) {
2946 content += "\nStage " + std::to_string(i + 1)
2947 + " (" + stage_log[i].target_tier + "): "
2948 + stage_log[i].summary;
2949 }
2950 content += "\nFinal: " + result.summary;
2951 Message result_msg;
2952 result_msg.role = "user";
2953 result_msg.content = std::move(content);
2954 ctx.messages.push_back(std::move(result_msg));
2955
2956 set_state(ctx, AgentState::EXECUTING);
2957}
2958
2967void AgentEngine::fire_delegation_start(
2968 const LoopContext& /*ctx*/,
2969 const std::string& tier,
2970 const std::string& task) {
2971 if (callbacks_.on_delegation_start != nullptr) {
2972 callbacks_.on_delegation_start(
2973 "", tier.c_str(), task.c_str(),
2974 callbacks_.user_data);
2975 }
2976}
2977
2986void AgentEngine::fire_delegation_complete(
2987 const LoopContext& /*ctx*/,
2988 const std::string& tier,
2989 const DelegationResult& result) {
2990 if (callbacks_.on_delegation_complete != nullptr) {
2991 callbacks_.on_delegation_complete(
2992 "", tier.c_str(), result.summary.c_str(),
2993 result.success ? 1 : 0,
2994 callbacks_.user_data);
2995 }
2996}
2997
2998// ── Auto-chain (v1.8.6) ─────────────────────────────────
2999
3009bool AgentEngine::should_auto_chain(
3010 const LoopContext& ctx,
3011 const std::string& finish_reason,
3012 const std::string& content) {
3013 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
3014 return false;
3015 }
3016
3017 std::string auto_chain = tier_res_.get_tier_param(
3018 ctx.locked_tier, "auto_chain", tier_res_.user_data);
3019 if (auto_chain.empty()) {
3020 return false;
3021 }
3022
3023 bool triggered = (finish_reason == "length") ||
3024 (finish_reason == "stop" &&
3025 response_generator_.is_response_complete(content, "[]"));
3026 return triggered;
3027}
3028
3038bool AgentEngine::try_auto_chain(
3039 LoopContext& ctx,
3040 const std::string& finish_reason,
3041 const std::string& content) {
3042 if (!should_auto_chain(ctx, finish_reason, content)) {
3043 return false;
3044 }
3045
3046 if (ctx.delegation_depth > 0) {
3047 logger->info("[AUTO-CHAIN] child depth={}, completing",
3048 ctx.delegation_depth);
3049 set_state(ctx, AgentState::COMPLETE);
3050 return true;
3051 }
3052
3053 // Root: tier change to auto_chain target
3054 std::string target = tier_res_.get_tier_param(
3055 ctx.locked_tier, "auto_chain", tier_res_.user_data);
3056
3057 if (!target.empty()) {
3058 logger->info("[AUTO-CHAIN] root, tier change to '{}'", target);
3059 TierChangeDirective tc(target, "auto_chain");
3060 DirectiveResult r;
3061 dir_tier_change(ctx, tc, r);
3062 }
3063 return !target.empty();
3064}
3065
3066// ── Project dir (v2.1.5, gh#29) ─────────────────────────
3067
3088std::filesystem::path AgentEngine::get_repo_dir() {
3089 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3090 return get_repo_dir_locked();
3091}
3092
3106std::filesystem::path AgentEngine::get_repo_dir_locked() {
3107 if (repo_dir_checked_) {
3108 return cached_repo_dir_.value_or(std::filesystem::path{});
3109 }
3110 repo_dir_checked_ = true;
3111 std::filesystem::path resolved;
3112 if (!project_dir_override_.empty()) {
3113 resolved = project_dir_override_;
3114 logger->info("Project dir for sandbox snapshots: {} (configured)",
3115 resolved.string());
3116 } else {
3117 resolved = std::filesystem::current_path();
3118 logger->info("Project dir for sandbox snapshots: {} (cwd fallback)",
3119 resolved.string());
3120 }
3121 cached_repo_dir_ = resolved;
3122 return resolved;
3123}
3124
3136void AgentEngine::set_project_dir(const std::filesystem::path& project_dir) {
3137 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3138 project_dir_override_ = project_dir;
3139 cached_repo_dir_.reset();
3140 repo_dir_checked_ = false;
3141}
3142
3159 LoopContext& ctx,
3160 const std::string& reason,
3161 const std::string& delegation_id) {
3162 Message m;
3163 m.role = "user";
3164 m.content = "[DELEGATION FAILED: resume_delegation] "
3165 + reason + " (delegation_id=" + delegation_id + ")";
3166 ctx.messages.push_back(std::move(m));
3167}
3168
3182bool AgentEngine::fetch_resume_payload(
3183 LoopContext& ctx,
3184 const std::string& id,
3185 nlohmann::json& parsed) {
3186 std::optional<std::string> error;
3187 std::string raw;
3188 if (storage_.load_delegation_with_messages == nullptr) {
3189 error = "storage unavailable";
3190 } else if (!storage_.load_delegation_with_messages(
3191 id.c_str(), raw, storage_.user_data)) {
3192 error = "unknown delegation_id";
3193 } else {
3194 parsed = nlohmann::json::parse(raw, nullptr, false);
3195 if (parsed.is_discarded() || !parsed.is_object()) {
3196 error = "malformed storage payload";
3197 }
3198 }
3199 if (error.has_value()) {
3200 logger->error("resume_delegation '{}': {}", id, *error);
3201 push_resume_failure(ctx, *error, id);
3202 return false;
3203 }
3204 return true;
3205}
3206
3221bool AgentEngine::resolve_latest_for_target(
3222 LoopContext& ctx, PendingDelegation& pending) {
3223 std::string id;
3224 bool found = storage_.latest_delegation_for_target != nullptr
3225 && storage_.latest_delegation_for_target(
3226 pending.target.c_str(), id, storage_.user_data)
3227 && !id.empty();
3228 if (!found) {
3230 ctx, "no prior delegation to this tier in storage — use "
3231 "entropic.delegate instead",
3232 pending.target);
3233 return false;
3234 }
3235 logger->info("resume_delegation by target '{}' resolved to id '{}'",
3236 pending.target, id);
3237 pending.resume_from_delegation_id = id;
3238 return true;
3239}
3240
3253bool AgentEngine::resolve_resume_delegation(
3254 LoopContext& ctx,
3255 PendingDelegation& pending,
3256 std::vector<Message>& out_history) {
3257 // gh#162 (v2.13.0): a lead that remembers the specialist but not the
3258 // storage id addresses the tier directly; the id is resolved here,
3259 // because the tool cannot see storage. Two round trips become one.
3260 // Single-exit accumulator — the knots returns gate is 3.
3261 nlohmann::json j;
3262 bool ok = !pending.resume_by_target
3263 || resolve_latest_for_target(ctx, pending);
3264 const auto& id = pending.resume_from_delegation_id;
3265 if (ok) { ok = fetch_resume_payload(ctx, id, j); }
3266 std::string target;
3267 if (ok) {
3268 target = j.value("target_tier", std::string{});
3269 if (target.empty()) {
3270 push_resume_failure(ctx, "target_tier missing in storage", id);
3271 ok = false;
3272 }
3273 }
3274 if (!ok) {
3275 return false;
3276 }
3277 pending.target = target;
3278 if (j.contains("messages") && j["messages"].is_array()) {
3279 for (const auto& mj : j["messages"]) {
3280 Message m;
3281 m.role = mj.value("role", "");
3282 m.content = mj.value("content", "");
3283 if (!m.role.empty()) {
3284 out_history.push_back(std::move(m));
3285 }
3286 }
3287 }
3288 logger->info("resume_delegation '{}': loaded {} messages, target='{}'",
3289 id, out_history.size(), target);
3290 return true;
3291}
3292
3307DelegationResult AgentEngine::run_pending_delegation(
3308 LoopContext& ctx,
3309 const PendingDelegation& pending,
3310 std::vector<Message> resume_history) {
3311 std::optional<int> max_turns;
3312 if (pending.max_turns > 0) { max_turns = pending.max_turns; }
3313 DelegationManager mgr(run_child_loop_trampoline, this,
3314 tier_res_, resolve_session_root(ctx.session_key),
3315 sandbox_for_session(ctx.session_key));
3316 auto swap = dir_swap_snapshot(); // gh#160
3317 mgr.set_dir_swap(swap.first, swap.second);
3318 if (storage_.create_delegation != nullptr) {
3319 mgr.set_storage(&storage_);
3320 }
3321 auto cb_snap = delegation_callbacks_snapshot();
3322 mgr.set_delegation_callbacks(
3323 cb_snap.start, cb_snap.complete, cb_snap.user_data);
3324 if (resume_history.empty()) {
3325 return mgr.execute_delegation(
3326 ctx, pending.target, pending.task, max_turns, pending.context);
3327 }
3328 return mgr.execute_resume_delegation(
3329 ctx, pending.target, pending.task,
3330 std::move(resume_history), max_turns, pending.context);
3331}
3332
3350SandboxManager* AgentEngine::ensure_sandbox_manager(
3351 const std::filesystem::path& root) {
3352 if (root.empty()) {
3353 return nullptr;
3354 }
3355 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3356 auto it = sandbox_mgrs_.find(root);
3357 if (it != sandbox_mgrs_.end()) {
3358 return &it->second;
3359 }
3360 // piecewise_construct: SandboxManager holds a mutex and an atomic, so it
3361 // is neither copyable nor movable — the map node must build it in place.
3362 auto [pos, inserted] = sandbox_mgrs_.emplace(
3363 std::piecewise_construct,
3364 std::forward_as_tuple(root),
3365 std::forward_as_tuple(root));
3366 (void)inserted;
3367 return &pos->second;
3368}
3369
3376void AgentEngine::set_session_root_interface(
3377 const SessionRootInterface& iface) {
3378 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3379 session_root_ = iface;
3380}
3381
3389void AgentEngine::set_dir_swap(ScopedSandbox::SwapDirFn swap_fn,
3390 void* user_data) {
3391 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3392 swap_dir_fn_ = swap_fn;
3393 swap_dir_data_ = user_data;
3394}
3395
3408std::pair<ScopedSandbox::SwapDirFn, void*>
3409AgentEngine::dir_swap_snapshot() const {
3410 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3411 return {swap_dir_fn_, swap_dir_data_};
3412}
3413
3422std::filesystem::path AgentEngine::resolve_session_root(
3423 const std::string& session_key) {
3425 {
3426 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3427 iface = session_root_;
3428 }
3429 if (iface.resolve_root == nullptr) {
3430 return get_repo_dir();
3431 }
3432 auto root = iface.resolve_root(session_key, iface.user_data);
3433 return root.empty() ? get_repo_dir() : root;
3434}
3435
3443SandboxManager* AgentEngine::sandbox_for_session(
3444 const std::string& session_key) {
3445 if (!loop_config_.delegation_isolation) {
3446 return nullptr;
3447 }
3448 return ensure_sandbox_manager(resolve_session_root(session_key));
3449}
3450
3459std::vector<std::string> AgentEngine::isolation_unsafe_tools(
3460 const LoopContext& ctx, const std::string& target_tier) {
3462 {
3463 std::lock_guard<std::mutex> guard(sandbox_mutex_);
3464 iface = session_root_;
3465 }
3466 if (!loop_config_.delegation_isolation
3467 || iface.unsafe_external_tools == nullptr) {
3468 return {};
3469 }
3470 auto info = tier_res_.resolve_tier
3471 ? tier_res_.resolve_tier(target_tier, tier_res_.user_data)
3472 : ChildContextInfo{};
3473 return iface.unsafe_external_tools(
3474 ctx.session_key, info.allowed_tools, iface.user_data);
3475}
3476
3477// ── Conversation state (v2.0.2) ─────────────────────────────
3478
3491void AgentEngine::set_system_prompt(const std::string& prompt) {
3492 std::lock_guard<std::mutex> guard(prompt_mutex_);
3493 system_prompt_ = prompt;
3494}
3495
3506std::string AgentEngine::system_prompt_copy() const {
3507 return session_system_prompt(active_session_key());
3508}
3509
3516void AgentEngine::set_session_logger(SessionLogger* log) {
3517 session_logger_ = log;
3518}
3519
3532ConversationState& AgentEngine::active_conversation() {
3533 const auto key = active_session_key();
3534 std::lock_guard<std::mutex> guard(conversations_mutex_);
3535 return conversations_[key];
3536}
3537
3538// ── Per-session run registry (gh#158, v2.13.0) ─────────────────
3539
3540namespace {
3541
3547thread_local std::string t_active_session_key;
3548thread_local bool t_active_session_bound = false;
3549
3551thread_local std::string t_claimed_key;
3552thread_local bool t_holds_claim = false;
3553
3554} // namespace
3555
3575std::string AgentEngine::active_session_key() const {
3576 if (t_active_session_bound) { return t_active_session_key; }
3577 std::lock_guard<std::mutex> guard(session_key_mutex_);
3578 return active_session_key_;
3579}
3580
3596void AgentEngine::set_active_session(const std::string& key) {
3597 {
3598 std::lock_guard<std::mutex> guard(conversations_mutex_);
3599 conversations_[key]; // default-construct on first use
3600 }
3601 t_active_session_key = key;
3602 t_active_session_bound = true;
3603 std::lock_guard<std::mutex> guard(session_key_mutex_);
3604 active_session_key_ = key;
3605}
3606
3614bool AgentEngine::try_begin_turn(const std::string& key) {
3615 {
3616 std::lock_guard<std::mutex> guard(runs_mutex_);
3617 // Same key twice is a genuine conflict whatever the configuration:
3618 // the two runs share one conversation. A DIFFERENT key is refused
3619 // only while per-session concurrency is off, which is the shipped
3620 // default and reproduces v2.12.0 exactly.
3621 const bool blocked = active_runs_.count(key) > 0
3622 || (!concurrent_sessions_ && !active_runs_.empty());
3623 if (blocked) { return false; }
3624 auto claimed = std::make_shared<RunState>();
3625 claimed->key = key;
3626 active_runs_[key] = claimed;
3627 running_flag_.store(true);
3628 }
3629 // The claim IS the session binding. Keeping them separate was a defect:
3630 // the binding is thread-local, so a binding that outlived its run stayed
3631 // on that thread and silently captured the NEXT unkeyed run — including
3632 // one on a different handle entirely.
3633 t_claimed_key = key;
3634 t_holds_claim = true;
3635 t_active_session_key = key;
3636 t_active_session_bound = true;
3637 return true;
3638}
3639
3646void AgentEngine::end_turn(const std::string& key) {
3647 {
3648 std::lock_guard<std::mutex> guard(runs_mutex_);
3649 active_runs_.erase(key);
3650 running_flag_.store(!active_runs_.empty());
3651 }
3652 // Releasing the claim releases the binding with it — see try_begin_turn.
3653 // Guarded on the key so a nested release cannot unbind an outer run.
3654 if (t_holds_claim && t_claimed_key == key) {
3655 t_holds_claim = false;
3656 t_active_session_bound = false;
3657 t_active_session_key.clear();
3658 }
3659}
3660
3666void AgentEngine::end_turn() {
3667 // Release by the key claimed on THIS thread. An unkeyed release that
3668 // assumed `""` would free the wrong session for every pre-gh#158 call
3669 // site the moment a consumer started naming sessions.
3670 end_turn(t_holds_claim ? t_claimed_key : active_session_key());
3671}
3672
3679std::size_t AgentEngine::active_run_count() const {
3680 std::lock_guard<std::mutex> guard(runs_mutex_);
3681 return active_runs_.size();
3682}
3683
3690void AgentEngine::set_concurrent_sessions(bool enabled) {
3691 std::lock_guard<std::mutex> guard(runs_mutex_);
3692 concurrent_sessions_ = enabled;
3693 logger->info("Per-session run concurrency {} (gh#158)",
3694 enabled ? "ENABLED" : "disabled");
3695}
3696
3703bool AgentEngine::concurrent_sessions() const {
3704 std::lock_guard<std::mutex> guard(runs_mutex_);
3705 return concurrent_sessions_;
3706}
3707
3714std::shared_ptr<AgentEngine::RunState> AgentEngine::this_thread_run() const {
3715 if (!t_holds_claim) { return nullptr; }
3716 std::lock_guard<std::mutex> guard(runs_mutex_);
3717 auto it = active_runs_.find(t_claimed_key);
3718 return it == active_runs_.end() ? nullptr : it->second;
3719}
3720
3728bool AgentEngine::interrupt_session(const std::string& key) {
3729 std::shared_ptr<RunState> run;
3730 {
3731 std::lock_guard<std::mutex> guard(runs_mutex_);
3732 auto it = active_runs_.find(key);
3733 if (it != active_runs_.end()) { run = it->second; }
3734 }
3735 if (!run) {
3736 logger->info("interrupt_session('{}'): no run in flight", key);
3737 return false;
3738 }
3739 run->interrupt.store(true, std::memory_order_release);
3740 logger->info("Session '{}' interrupted", key);
3741 return true;
3742}
3743
3751bool AgentEngine::session_interrupted(const std::string& key) const {
3752 std::lock_guard<std::mutex> guard(runs_mutex_);
3753 auto it = active_runs_.find(key);
3754 return it != active_runs_.end()
3755 && it->second->interrupt.load(std::memory_order_acquire);
3756}
3757
3764std::atomic<bool>& AgentEngine::run_cancel_flag() {
3765 auto run = this_thread_run();
3766 return run ? run->interrupt : interrupt_flag_;
3767}
3768
3782std::vector<Message> AgentEngine::run_turn(const std::string& input) {
3783 // gh#40 (v2.1.10): the drain loop turns mid-generation queued user
3784 // messages into subsequent turns at this single top-level COMPLETE
3785 // boundary. running_flag_ lets the facade reject
3786 // entropic_queue_user_message with INVALID_STATE when idle.
3787 //
3788 // gh#144 (v2.12.0): claim only if the caller has not already. The
3789 // facade claims BEFORE calling in, and must keep the claim until it
3790 // has finished serialising results out of conversation_ — if this
3791 // function released the flag on its way out, a second thread could
3792 // start appending to that vector while the first was still reading it.
3793 // A direct engine caller (tests, embedders) still gets is_running()
3794 // set for the duration, preserving the gh#40 contract.
3795 const bool owns_turn = try_begin_turn();
3796 auto& convo = active_conversation();
3797 auto prompt = system_prompt_copy();
3798 if (convo.messages.empty() && !prompt.empty()) {
3799 Message sys;
3800 sys.role = "system";
3801 sys.content = std::move(prompt);
3802 convo.messages.push_back(std::move(sys));
3803 }
3804 auto result = run_drain_loop(input, /*tier_override=*/"");
3805 if (owns_turn) { end_turn(); }
3806 return result;
3807}
3808
3826std::vector<Message> AgentEngine::run_turn_as(const std::string& tier,
3827 const std::string& input) {
3828 // gh#144 (v2.12.0): see run_turn(const std::string&) — claim only if
3829 // the caller has not already.
3830 const bool owns_turn = try_begin_turn();
3831 seed_system_prompt_for_tier(tier);
3832 auto result = run_drain_loop(input, tier);
3833 if (owns_turn) { end_turn(); }
3834 return result;
3835}
3836
3844void AgentEngine::set_workspace_prompts(const std::string& workspace,
3845 WorkspacePrompts prompts) {
3846 logger->info("[workspace] '{}' prompts set: default {} chars, {} tier(s)",
3847 workspace, prompts.default_prompt.size(),
3848 prompts.tier_prompts.size());
3849 std::lock_guard<std::mutex> guard(prompt_mutex_);
3850 workspace_prompts_[workspace] = std::move(prompts);
3851}
3852
3859void AgentEngine::clear_workspace_prompts(const std::string& workspace) {
3860 logger->info("[workspace] '{}' prompts cleared — its sessions use the "
3861 "handle's", workspace);
3862 std::lock_guard<std::mutex> guard(prompt_mutex_);
3863 workspace_prompts_.erase(workspace);
3864}
3865
3873void AgentEngine::bind_session_workspace(const std::string& session,
3874 const std::string& workspace) {
3875 std::lock_guard<std::mutex> guard(prompt_mutex_);
3876 session_workspace_[session] = workspace;
3877}
3878
3887std::optional<std::string> AgentEngine::workspace_prompt_for(
3888 const std::string& session, const std::string& tier) const {
3889 std::lock_guard<std::mutex> guard(prompt_mutex_);
3890 std::optional<std::string> out;
3891 auto bound = session_workspace_.find(session);
3892 auto ws = bound == session_workspace_.end()
3893 ? workspace_prompts_.end() : workspace_prompts_.find(bound->second);
3894 if (ws != workspace_prompts_.end() && tier.empty()) {
3895 out = ws->second.default_prompt;
3896 } else if (ws != workspace_prompts_.end()) {
3897 auto t = ws->second.tier_prompts.find(tier);
3898 if (t != ws->second.tier_prompts.end()) { out = t->second; }
3899 }
3900 return out;
3901}
3902
3911std::string AgentEngine::session_system_prompt(const std::string& session) const {
3912 auto ws = workspace_prompt_for(session, "");
3913 std::lock_guard<std::mutex> guard(prompt_mutex_);
3914 return ws.value_or(system_prompt_);
3915}
3916
3930void AgentEngine::seed_system_prompt_for_tier(const std::string& tier) {
3931 auto& convo = active_conversation();
3932 if (!convo.messages.empty()) { return; }
3933 auto sp = workspace_prompt_for(active_session_key(), tier);
3934 auto it = tier_info_.find(tier);
3935 if (!sp && it != tier_info_.end() && !it->second.system_prompt.empty()) {
3936 sp = it->second.system_prompt;
3937 }
3938 if (!sp) { sp = system_prompt_copy(); }
3939 if (sp->empty()) { return; }
3940 Message sys;
3941 sys.role = "system";
3942 sys.content = *sp;
3943 convo.messages.push_back(std::move(sys));
3944}
3945
3954std::vector<Message> AgentEngine::run_drain_loop(
3955 std::string pending, const std::string& tier_override) {
3956 std::vector<Message> result;
3957 while (true) {
3958 Message usr;
3959 usr.role = "user";
3960 usr.content = pending;
3961 auto& convo = active_conversation();
3962 convo.messages.push_back(std::move(usr));
3963
3964 size_t sent_len = convo.messages.size();
3965 // copy — run() may mutate
3966 result = run(convo.messages, tier_override);
3967 for (size_t i = sent_len; i < result.size(); ++i) {
3968 convo.messages.push_back(result[i]);
3969 }
3970 auto next = pop_queued_user_message();
3971 if (!next.has_value()) { break; }
3972 fire_queue_consumed(*next, user_message_queue_depth());
3973 pending = std::move(*next);
3974 }
3975 return result;
3976}
3977
4000void AgentEngine::seed_system_prompt(
4001 const std::vector<Message>& new_messages) {
4002 bool caller_has_system = false;
4003 for (const auto& m : new_messages) {
4004 if (m.role == "system") { caller_has_system = true; break; }
4005 }
4006 auto& convo = active_conversation();
4007 auto prompt = system_prompt_copy();
4008 if (convo.messages.empty() && !prompt.empty()
4009 && !caller_has_system) {
4010 Message sys;
4011 sys.role = "system";
4012 sys.content = std::move(prompt);
4013 convo.messages.push_back(std::move(sys));
4014 }
4015}
4016
4024bool AgentEngine::prepare_next_turn(std::vector<Message>& pending) {
4025 auto next = pop_queued_user_message();
4026 if (!next.has_value()) { return false; }
4027 fire_queue_consumed(*next, user_message_queue_depth());
4028 Message usr;
4029 usr.role = "user";
4030 usr.content = std::move(*next);
4031 pending.clear();
4032 pending.push_back(std::move(usr));
4033 return true;
4034}
4035
4043std::vector<Message> AgentEngine::run_turn(std::vector<Message> new_messages) {
4044 // gh#40 (v2.1.10): mirror the single-string overload's drain
4045 // loop. Queued messages enqueued via entropic_queue_user_message
4046 // become subsequent plain-text user turns at this top-level
4047 // boundary (no content_parts — the queue ABI is text-only).
4048 // gh#144 (v2.12.0): see run_turn(const std::string&).
4049 const bool owns_turn = try_begin_turn();
4050 seed_system_prompt(new_messages);
4051 auto& convo = active_conversation();
4052 std::vector<Message> pending = std::move(new_messages);
4053 std::vector<Message> result;
4054 while (true) {
4055 for (auto& m : pending) {
4056 convo.messages.push_back(std::move(m));
4057 }
4058 size_t sent_len = convo.messages.size();
4059 result = run(convo.messages);
4060 for (size_t i = sent_len; i < result.size(); ++i) {
4061 convo.messages.push_back(result[i]);
4062 }
4063 if (!prepare_next_turn(pending)) { break; }
4064 }
4065 if (owns_turn) { end_turn(); }
4066 return result;
4067}
4068
4069namespace {
4070
4072struct StreamChunkCtx {
4073 StreamThinkFilter* filter;
4074 int* cancel;
4075 AgentEngine* engine;
4076};
4077
4095void on_stream_chunk(const char* t, size_t l, void* ud) {
4096 auto* c = static_cast<StreamChunkCtx*>(ud);
4097 if (c->cancel != nullptr && *c->cancel != 0) {
4098 if (!entropic::cancel_current_run()) { c->engine->interrupt(); }
4099 return;
4100 }
4101 c->filter->on_token(t, l);
4102}
4103
4104} // namespace
4105
4119int AgentEngine::run_streaming(
4120 const std::string& input,
4121 TokenCallback on_token,
4122 void* user_data,
4123 int* cancel_flag,
4124 const std::string& tier)
4125{
4126 if (session_logger_) {
4127 session_logger_->log_user_input(input);
4128 }
4129
4130 StreamThinkFilter filter(on_token, user_data);
4131 if (session_logger_ && session_logger_->is_open()) {
4132 filter.set_raw_callback(
4133 SessionLogger::raw_token_callback,
4134 session_logger_);
4135 }
4136
4137 StreamChunkCtx sctx{&filter, cancel_flag, this};
4138 // gh#208 (v2.14.2): this run's sink, published to this run's thread. It
4139 // was stored in the handle-wide callbacks_ slot, where a concurrent
4140 // streaming run overwrote it: tokens crossed between clients, and the
4141 // first run to return left the other streaming into a dead frame.
4142 RunStreamScope stream_scope(on_stream_chunk, &sctx);
4143
4144 // gh#205: a tier (or identity) locks this turn exactly as run_turn_as does.
4145 auto result = tier.empty() ? run_turn(input) : run_turn_as(tier, input);
4146 filter.flush();
4147
4148 if (session_logger_) {
4149 session_logger_->end_turn();
4150 }
4151 if (cancel_flag && *cancel_flag) { return 1; }
4152 return 0;
4153}
4154
4162static std::string concat_user_echo(
4163 const std::vector<Message>& messages) {
4164 std::string echo;
4165 for (const auto& m : messages) {
4166 if (m.role != "user" || m.content.empty()) { continue; }
4167 if (!echo.empty()) { echo += '\n'; }
4168 echo += m.content;
4169 }
4170 return echo;
4171}
4172
4189int AgentEngine::run_streaming(
4190 std::vector<Message> new_messages,
4191 TokenCallback on_token,
4192 void* user_data,
4193 int* cancel_flag)
4194{
4195 if (session_logger_) {
4196 session_logger_->log_user_input(concat_user_echo(new_messages));
4197 }
4198
4199 StreamThinkFilter filter(on_token, user_data);
4200 if (session_logger_ && session_logger_->is_open()) {
4201 filter.set_raw_callback(
4202 SessionLogger::raw_token_callback,
4203 session_logger_);
4204 }
4205
4206 StreamChunkCtx sctx{&filter, cancel_flag, this};
4207 // gh#208 (v2.14.2): this run's sink, published to this run's thread. It
4208 // was stored in the handle-wide callbacks_ slot, where a concurrent
4209 // streaming run overwrote it: tokens crossed between clients, and the
4210 // first run to return left the other streaming into a dead frame.
4211 RunStreamScope stream_scope(on_stream_chunk, &sctx);
4212
4213 auto result = run_turn(std::move(new_messages));
4214 filter.flush();
4215
4216 if (session_logger_) {
4217 session_logger_->end_turn();
4218 }
4219 if (cancel_flag && *cancel_flag) { return 1; }
4220 return 0;
4221}
4222
4228void AgentEngine::clear_conversation() {
4229 active_conversation().clear();
4230 logger->info("conversation cleared");
4231}
4232
4248size_t AgentEngine::message_count() const {
4249 const auto key = active_session_key();
4250 std::lock_guard<std::mutex> guard(conversations_mutex_);
4251 auto it = conversations_.find(key);
4252 return it == conversations_.end() ? 0 : it->second.count();
4253}
4254
4255// ── Keyed conversation accessors (gh#144; locked for gh#165) ───
4256
4264std::vector<Message> AgentEngine::messages_for(const std::string& key) const {
4265 std::lock_guard<std::mutex> guard(conversations_mutex_);
4266 auto it = conversations_.find(key);
4267 return it == conversations_.end() ? std::vector<Message>{}
4268 : it->second.messages;
4269}
4270
4278std::size_t AgentEngine::message_count_for(const std::string& key) const {
4279 std::lock_guard<std::mutex> guard(conversations_mutex_);
4280 auto it = conversations_.find(key);
4281 return it == conversations_.end() ? 0 : it->second.count();
4282}
4283
4290void AgentEngine::clear_conversation_for(const std::string& key) {
4291 std::lock_guard<std::mutex> guard(conversations_mutex_);
4292 auto it = conversations_.find(key);
4293 if (it != conversations_.end()) { it->second.clear(); }
4294}
4295
4303bool AgentEngine::drop_session(const std::string& key) {
4304 std::lock_guard<std::mutex> guard(conversations_mutex_);
4305 if (key.empty()) {
4306 conversations_[key].clear();
4307 return true;
4308 }
4309 return conversations_.erase(key) > 0;
4310}
4311
4318std::vector<std::string> AgentEngine::session_keys() const {
4319 std::lock_guard<std::mutex> guard(conversations_mutex_);
4320 std::vector<std::string> keys;
4321 keys.reserve(conversations_.size());
4322 for (const auto& [k, v] : conversations_) { keys.push_back(k); }
4323 return keys;
4324}
4325
4341bool AgentEngine::set_session_messages(const std::string& key,
4342 std::vector<Message> messages) {
4343 {
4344 std::lock_guard<std::mutex> guard(runs_mutex_);
4345 if (active_runs_.count(key) > 0) {
4346 logger->warn("session_context_set('{}') refused: a run on that "
4347 "session is in flight", key);
4348 return false;
4349 }
4350 }
4351 std::lock_guard<std::mutex> guard(conversations_mutex_);
4352 auto& convo = conversations_[key];
4353 convo.messages = std::move(messages);
4354 logger->info("Session '{}' conversation restored: {} messages", key,
4355 convo.messages.size());
4356 return true;
4357}
4358
4365const std::vector<Message>& AgentEngine::get_messages() const {
4366 static const std::vector<Message> kNoMessages;
4367 const auto key = active_session_key();
4368 // The lock guards the LOOKUP, not the returned reference — an
4369 // `unordered_map` insert on another thread rehashes the bucket array,
4370 // and traversing it mid-rehash is undefined behaviour whatever the
4371 // caller does afterwards. Mapped values do not move on rehash, which is
4372 // why this container was chosen (see the member's doc), so the
4373 // reference stays valid for as long as the entry does. A caller that
4374 // needs more than that wants the keyed sibling `messages_for`, which
4375 // returns by value; this overload survives for pre-v2.12.0 callers.
4376 std::lock_guard<std::mutex> guard(conversations_mutex_);
4377 auto it = conversations_.find(key);
4378 return it == conversations_.end() ? kNoMessages : it->second.messages;
4379}
4380
4381// ── Mid-generation user-message queue (gh#40, v2.1.10) ─────────
4382
4388bool AgentEngine::queue_user_message(const std::string& message) {
4389 std::lock_guard lock(queue_mutex_);
4390 int cap = loop_config_.message_queue_capacity;
4391 if (cap < 0) { cap = 0; }
4392 if (user_message_queue_.size()
4393 >= static_cast<size_t>(cap)) {
4394 return false;
4395 }
4396 user_message_queue_.push_back(message);
4397 logger->info("queued mid-gen user message: depth={}",
4398 user_message_queue_.size());
4399 return true;
4400}
4401
4407size_t AgentEngine::user_message_queue_depth() const {
4408 std::lock_guard lock(queue_mutex_);
4409 return user_message_queue_.size();
4410}
4411
4417void AgentEngine::clear_user_message_queue() {
4418 std::lock_guard lock(queue_mutex_);
4419 size_t dropped = user_message_queue_.size();
4420 user_message_queue_.clear();
4421 if (dropped > 0) {
4422 logger->info("cleared mid-gen queue: dropped={}", dropped);
4423 }
4424}
4425
4431void AgentEngine::set_message_queue_capacity(int cap) {
4432 std::lock_guard lock(queue_mutex_);
4433 loop_config_.message_queue_capacity = cap < 0 ? 0 : cap;
4434}
4435
4441std::optional<std::string>
4442AgentEngine::pop_queued_user_message() {
4443 std::lock_guard lock(queue_mutex_);
4444 if (user_message_queue_.empty()) {
4445 return std::nullopt;
4446 }
4447 std::string front = std::move(user_message_queue_.front());
4448 user_message_queue_.pop_front();
4449 return front;
4450}
4451
4457void AgentEngine::fire_queue_consumed(const std::string& consumed,
4458 size_t remaining) {
4459 if (queue_observer_ != nullptr) {
4460 queue_observer_(
4461 consumed.c_str(), remaining, queue_observer_data_);
4462 }
4463}
4464
4470void AgentEngine::set_queue_observer(
4471 void (*observer)(const char*, size_t, void*),
4472 void* user_data) {
4473 queue_observer_ = observer;
4474 queue_observer_data_ = user_data;
4475}
4476
4488void AgentEngine::set_state_observer(
4489 void (*observer)(int, void*),
4490 void* user_data) {
4491 state_observer_ = observer;
4492 state_observer_data_ = user_data;
4493 response_generator_.set_state_observer(observer, user_data);
4494}
4495
4496// ── Directive hooks (v2.0.2) ────────────────────────────────
4497
4504ToolExecutorHooks AgentEngine::build_directive_hooks() {
4505 ToolExecutorHooks hooks;
4506 hooks.process_directives = [](
4507 LoopContext& ctx,
4508 const std::vector<const Directive*>& dirs,
4509 void* ud) -> DirectiveResult {
4510 return static_cast<AgentEngine*>(ud)
4511 ->directive_processor().process(ctx, dirs);
4512 };
4513 hooks.user_data = this;
4514 return hooks;
4515}
4516
4517// ── Tier resolution (v2.0.2) ────────────────────────────────
4518
4526void AgentEngine::set_tier_info(
4527 const std::string& name,
4528 const ChildContextInfo& info)
4529{
4530 tier_info_[name] = info;
4531}
4532
4540bool AgentEngine::has_tier(const std::string& name) const {
4541 return tier_info_.count(name) > 0;
4542}
4543
4552std::vector<std::string> AgentEngine::get_tier_allowed_tools(
4553 const std::string& name) const {
4554 auto it = tier_info_.find(name);
4555 if (it == tier_info_.end()) { return {}; }
4556 return it->second.allowed_tools;
4557}
4558
4566const std::string& AgentEngine::tier_system_prompt(
4567 const std::string& name) const {
4568 static const std::string kEmpty;
4569 auto it = tier_info_.find(name);
4570 return it == tier_info_.end() ? kEmpty : it->second.system_prompt;
4571}
4572
4579void AgentEngine::set_relay_single_delegate(const std::string& name) {
4580 relay_single_delegate_tiers_.insert(name);
4581}
4582
4590void AgentEngine::set_handoff_rules(
4591 const std::unordered_map<std::string,
4592 std::vector<std::string>>& rules)
4593{
4594 handoff_rules_ = rules;
4595 wire_internal_tier_resolution();
4596}
4597
4610ChildContextInfo AgentEngine::tri_resolve_tier(
4611 const std::string& name, void* ud) {
4612 auto* self = static_cast<AgentEngine*>(ud);
4613 auto it = self->tier_info_.find(name);
4614 ChildContextInfo info;
4615 if (it == self->tier_info_.end()) {
4616 info.valid = false;
4617 } else {
4618 info = it->second;
4619 info.system_prompt = self->workspace_prompt_for(
4620 self->active_session_key(), name)
4621 .value_or(info.system_prompt);
4622 }
4623 return info;
4624}
4625
4634bool AgentEngine::tri_tier_exists(const std::string& name, void* ud) {
4635 auto* self = static_cast<AgentEngine*>(ud);
4636 return self->tier_info_.count(name) > 0;
4637}
4638
4648std::vector<std::string> AgentEngine::tri_get_handoff_targets(
4649 const std::string& name, void* ud) {
4650 auto* self = static_cast<AgentEngine*>(ud);
4651 auto it = self->handoff_rules_.find(name);
4652 std::vector<std::string> result;
4653 if (it != self->handoff_rules_.end()) { result = it->second; }
4654 return result;
4655}
4656
4664static std::string join_csv(const std::vector<std::string>& v) {
4665 std::string out;
4666 for (const auto& s : v) {
4667 if (!out.empty()) { out += ','; }
4668 out += s;
4669 }
4670 return out;
4671}
4672
4697std::string AgentEngine::tri_get_tier_param(const std::string& name,
4698 const std::string& param, void* ud) {
4699 auto* self = static_cast<AgentEngine*>(ud);
4700 auto it = self->tier_info_.find(name);
4701 if (it == self->tier_info_.end()) { return ""; }
4702 const auto& info = it->second;
4703 std::string result;
4704 if (param == "explicit_completion") {
4705 result = info.explicit_completion ? "true" : "false";
4706 }
4707 if (param == "max_iterations" && info.max_iterations_override >= 0) {
4708 result = std::to_string(info.max_iterations_override);
4709 }
4710 if (param == "max_tool_calls_per_turn"
4711 && info.max_tool_calls_per_turn_override >= 0) {
4712 result = std::to_string(info.max_tool_calls_per_turn_override);
4713 }
4714 if (param == "max_consecutive_empty_turns"
4715 && info.max_consecutive_empty_turns_override >= 0) {
4716 result = std::to_string(info.max_consecutive_empty_turns_override);
4717 }
4718 if (param == "allowed_tools" && !info.allowed_tools.empty()) {
4719 result = join_csv(info.allowed_tools);
4720 }
4721 return result;
4722}
4723
4729void AgentEngine::wire_internal_tier_resolution() {
4730 TierResolutionInterface tri;
4731 tri.resolve_tier = &AgentEngine::tri_resolve_tier;
4732 tri.tier_exists = &AgentEngine::tri_tier_exists;
4733 tri.get_handoff_targets = &AgentEngine::tri_get_handoff_targets;
4734 tri.get_tier_param = &AgentEngine::tri_get_tier_param;
4735 tri.user_data = this;
4736 set_tier_resolution(tri);
4737}
4738
4739} // namespace entropic
Core agent execution engine.
Definition engine.h:65
void run_loop(LoopContext &ctx, bool inherit_interrupt=false)
Run the engine loop on a pre-built context.
Definition engine.cpp:408
AgentEngine(const InferenceInterface &inference, const LoopConfig &loop_config, const CompactionConfig &compaction_config)
Construct an agent engine.
Definition engine.cpp:136
RAII publication of one run's cancel token to its own thread.
Definition run_scope.h:98
RAII: publish a streaming run's sink to its own thread (gh#208).
Definition run_scope.h:217
Create, finalize, and discard per-delegation filesystem sandboxes.
Definition sandbox.h:116
void(*)(const std::string &session_key, const std::filesystem::path &path, bool entering, void *user_data) SwapDirFn
Callback type for directory swapping.
Definition sandbox.h:361
Manages session_model.log for raw streaming content.
Streaming filter that removes <think> blocks from output.
Testable free function extracted from fire_delegate_complete_hook.
std::string build_delegate_complete_json(const std::string &target, bool success, const std::string &summary)
Build and dump the ON_DELEGATE_COMPLETE hook JSON payload.
DelegationManager — child loop creation and execution.
AgentEngine * engine
Fallback interrupt target.
Definition engine.cpp:4075
int * cancel
Consumer's cancel flag, nullable.
Definition engine.cpp:4074
StreamThinkFilter * filter
Consumer-facing filter.
Definition engine.cpp:4073
Core agent execution engine.
ent_decision_t
Consumer decision returned from delegation callbacks.
Definition entropic.h:1614
entropic_directive_type_t
Directive types emitted by MCP tool results.
Definition enums.h:58
@ ENTROPIC_DIRECTIVE_TIER_CHANGE
Switch active tier.
Definition enums.h:60
@ ENTROPIC_DIRECTIVE_STOP_PROCESSING
Halt directive processing.
Definition enums.h:59
@ ENTROPIC_DIRECTIVE_PRUNE_MESSAGES
Prune old tool results.
Definition enums.h:66
@ ENTROPIC_DIRECTIVE_INJECT_CONTEXT
Inject message into context.
Definition enums.h:65
@ ENTROPIC_DIRECTIVE_COMPLETE
Mark task complete.
Definition enums.h:63
@ ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER
Generic UI notification passthrough.
Definition enums.h:69
@ ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS
Clear self-directed todos (engine no-op)
Definition enums.h:64
@ ENTROPIC_DIRECTIVE_PHASE_CHANGE
Switch active inference phase.
Definition enums.h:68
@ ENTROPIC_DIRECTIVE_PIPELINE
Multi-stage sequential execution.
Definition enums.h:62
@ ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR
Replace context anchor.
Definition enums.h:67
@ ENTROPIC_DIRECTIVE_DELEGATE
Route to another identity.
Definition enums.h:61
entropic_hook_point_t
Hook points in the engine lifecycle.
Definition hooks.h:39
@ ENTROPIC_HOOK_ON_LOOP_START
20: Agentic loop entry
Definition hooks.h:63
@ ENTROPIC_HOOK_ON_DELEGATE
8: Delegation to child tier started
Definition hooks.h:49
@ ENTROPIC_HOOK_ON_LOOP_END
21: Agentic loop exit
Definition hooks.h:64
@ ENTROPIC_HOOK_ON_STATE_CHANGE
6: Engine state machine transition
Definition hooks.h:47
@ ENTROPIC_HOOK_PRE_GENERATE
0: Before inference generate call
Definition hooks.h:41
@ ENTROPIC_HOOK_ON_CONTEXT_ASSEMBLE
10: Context window assembled
Definition hooks.h:51
@ ENTROPIC_HOOK_ON_LOOP_ITERATION
5: Each agentic loop iteration
Definition hooks.h:46
@ ENTROPIC_HOOK_ON_ERROR
7: Async error occurred
Definition hooks.h:48
@ ENTROPIC_HOOK_POST_GENERATE
1: After inference generate returns
Definition hooks.h:42
@ ENTROPIC_HOOK_ON_DELEGATE_COMPLETE
9: Child delegation completed
Definition hooks.h:50
@ ENTROPIC_HOOK_ON_COMPLETE
entropic.complete MCP tool called — pre-hook, can cancel.
Definition hooks.h:92
std::size_t remaining
Unexamined count.
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).
@ enabled
Named in enabled_mcpjson_servers, or enable-all is set.
static std::string build_coverage_gap_message(const std::string &tier, const DelegationResult &result)
Build the [COVERAGE GAP] message body that goes back to lead when a relay-tier child returns coverage...
Definition engine.cpp:2712
static void fire_context_assemble_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_CONTEXT_ASSEMBLE info hook.
Definition engine.cpp:102
static std::string extract_system_prompt(const std::vector< Message > &messages)
Extract the first system message content from messages.
Definition engine.cpp:2116
static double now_seconds()
Get current time as seconds since epoch.
Definition engine.cpp:120
static ToolCall build_tool_call_from_json(const nlohmann::json &obj, const std::string &tc_str)
Parse tool calls from raw model output.
Definition engine.cpp:1713
ToolResultKind
Categorical outcome of a single tool invocation.
Definition tool_result.h:32
@ ok
Tool dispatched, returned non-empty content.
@ error
Tool server returned an error payload.
@ delegation_failed
entropic.delegate child failed (terminal_reason or budget). (#7, v2.1.4)
static std::string build_tool_results_json(const std::vector< Message > &messages)
Build a JSON array of tool results from context messages.
Definition engine.cpp:2256
@ rejected
The approver said no.
@ optional
optional: every escape asks the host's path approver
static std::vector< ToolCall > decode_tool_calls_json(const std::string &tc_str)
Decode a JSON tool-calls array string into ToolCall vector.
Definition engine.cpp:1736
static std::string json_escape_engine(const std::string &s)
JSON-escape a string (no surrounding quotes).
Definition engine.cpp:1949
static std::string build_tool_evidence(const std::vector< Message > &messages, size_t max_results, size_t chars_per_result)
Build un-pruned tool-result evidence for the validator.
Definition engine.cpp:2056
@ count
Sentinel — MUST remain last.
static void remove_anchor_messages(LoopContext &ctx, const std::string &key)
Remove messages with a specific anchor key.
Definition engine.cpp:1882
const char * agent_state_name(AgentState state)
Get the string name for an AgentState value.
static void fold_delegation_summary(LoopContext &ctx, const std::string &summary)
gh#119 (v2.9.17): fold child summary into the lead's empty delegate turn.
Definition engine.cpp:2742
static void push_delegation_repeat_blocked(LoopContext &ctx, const std::string &target, int n)
Append a "stop retrying same target" reject message (gh#64).
Definition engine.cpp:2513
static void fire_loop_start_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_START info hook.
Definition engine.cpp:53
static std::string build_tool_manifest(const std::vector< Message > &messages)
Build tool call manifest from conversation messages.
Definition engine.cpp:1972
ENTROPIC_EXPORT bool current_run_cancelled()
Whether THIS thread's run has been cancelled.
Definition run_scope.cpp:45
static std::string format_tool_evidence_entry(const Message &msg, size_t chars_per_result)
Format one tool-result message for the evidence block.
Definition engine.cpp:2004
static const std::array< const char *, 256 > & json_escape_table()
256-entry table mapping bytes to JSON escape sequences.
Definition engine.cpp:1929
static std::string enrich_manifest_with_history(const std::string &base, const ToolExecutionInterface &tool_exec)
Append executor history (if any) to the tool manifest.
Definition engine.cpp:2098
ENTROPIC_EXPORT bool cancel_current_run()
Cancel the run executing on this thread (gh#158).
Definition run_scope.cpp:68
static void push_delegation_result(LoopContext &ctx, const std::string &target, const DelegationResult &result)
Append a delegation result message to the loop context.
Definition engine.cpp:2493
static std::string annotate_hard_stop(const std::string &carried, const std::string &reason, const std::string &stop_noun)
Build an engine-forced stop's final assistant content.
Definition engine.cpp:648
static std::string join_csv(const std::vector< std::string > &v)
Join a vector of strings with comma separators.
Definition engine.cpp:4664
static void run_child_loop_trampoline(LoopContext &ctx, void *user_data)
Trampoline for DelegationManager to call engine loop.
Definition engine.cpp:2433
const char * result_kind_to_string(ToolResultKind kind)
Serialize a ToolResultKind to its wire-stable string form.
Definition tool_result.h:51
static void fire_hook_info(const HookInterface &hooks, entropic_hook_point_t point, const char *json)
Fire an informational hook if the interface is wired.
Definition engine.cpp:38
void(*)(const char *, size_t, void *) TokenCallback
Token callback type matching the C API signature.
static void push_delegation_rejected(LoopContext &ctx)
Append a delegation rejection message to the loop context.
Definition engine.cpp:2446
static std::string concat_user_echo(const std::vector< Message > &messages)
Concatenate user-role message text for session-log echo.
Definition engine.cpp:4162
AgentState
C++ enum class for agent execution states.
static std::string last_substantive_assistant(const LoopContext &ctx)
The last assistant message with something in it (gh#169).
Definition engine.cpp:610
static void fire_loop_iteration_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_ITERATION info hook.
Definition engine.cpp:85
static void push_resume_failure(LoopContext &ctx, const std::string &reason, const std::string &delegation_id)
Lazy session-scoped SandboxManager accessor (gh#33, v2.1.6).
Definition engine.cpp:3158
static void push_delegation_cycle_rejected(LoopContext &ctx, const std::string &target)
Append a structured cycle-rejection message to the loop context so the model can recover.
Definition engine.cpp:2466
static void push_delegation_isolation_unsafe(LoopContext &ctx, const std::string &target, const std::vector< std::string > &tools)
Append the "isolation cannot be honoured" reject message (gh#160).
Definition engine.cpp:2544
static void fire_loop_end_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_END info hook.
Definition engine.cpp:69
gh#158: the cancel token of the run executing on THIS thread.
Filesystem-based sandbox isolation for delegations.
Request describing a delegation that is about to run.
Definition entropic.h:1628
Result of a finalized delegation, delivered to the consumer.
Definition entropic.h:1651
Grouped consumer-registered delegation callbacks (gh#29).
Definition engine.h:92
Resolved tier information for building child delegation contexts.
Auto-compaction configuration.
Definition config.h:1059
Engine-level hooks called during context management.
One caller-scoped conversation and its KV bookkeeping.
Result returned from a child delegation loop.
Definition delegation.h:33
bool success
Whether child reached COMPLETE via real entropic.complete.
Definition delegation.h:35
std::string summary
Final summary from child.
Definition delegation.h:34
std::vector< std::string > suggested_files
Issue #10 (v2.1.4): file paths the lead should inspect to fill the coverage gap.
Definition delegation.h:57
std::string gap_description
Issue #10 (v2.1.4): concrete description of what the child's answer DOES NOT cover.
Definition delegation.h:54
Aggregate result of processing a batch of directives.
Definition directives.h:309
Callback function pointer types for engine events.
Configuration for the agentic loop.
Mutable state carried through the agentic loop.
std::string session_key
Caller-scoped session this turn belongs to (gh#144, v2.12.0).
std::vector< std::string > delegation_ancestor_tiers
Tier stack from root to this loop (P1-9, 2.0.6-rc16)
std::string last_failed_delegation_target
gh#64: target tier of the most recent FAILED delegation (DelegationResult.success == false).
LoopMetrics metrics
Timing and counts.
int effective_max_iterations
Per-identity override (-1 = LoopConfig, P3-18)
int consecutive_duplicate_attempts
Stuck-model detector.
int consecutive_failed_delegations
gh#64: count of consecutive failed delegations against last_failed_delegation_target.
std::string conversation_id
Conversation ID for storage (v1.8.8)
bool has_pending_tool_results
Tool results awaiting presentation.
std::unordered_map< std::string, std::string > metadata
Runtime metadata.
int effective_max_tool_calls_per_turn
Per-identity override (-1 = LoopConfig, P3-18)
std::vector< Message > messages
Conversation history.
std::string locked_tier
Tier locked for this loop ("" = none)
Metrics collected during loop execution.
double start_time
Loop start (seconds since epoch)
int duration_ms() const
Get loop duration in milliseconds.
double end_time
Loop end (seconds since epoch)
int iterations
Total iterations completed.
A message in a conversation.
Definition message.h:36
std::unordered_map< std::string, std::string > metadata
Arbitrary metadata.
Definition message.h:40
std::string content
Message text content (always populated)
Definition message.h:38
std::string role
Message role.
Definition message.h:37
Where a session's tools live, and what it can reach (gh#160).
void * user_data
Opaque pointer (facade context)
std::filesystem::path(* resolve_root)(const std::string &session_key, void *user_data)
Root directory this session's MCP tools resolve against.
std::vector< std::string >(* unsafe_external_tools)(const std::string &session_key, const std::vector< std::string > &allowed_tools, void *user_data)
Child-visible EXTERNAL tools that are not declared read-only.
Storage interface for conversation persistence.
Tier resolution callbacks for delegation and auto-chain.
std::string(* get_tier_param)(const std::string &tier_name, const std::string &param_name, void *user_data)
Get a string parameter from tier identity frontmatter.
A tool call request parsed from model output.
Definition tool_call.h:31
std::unordered_map< std::string, std::string > arguments
Tool arguments as string key-value pairs.
Definition tool_call.h:34
std::string id
Unique call ID (UUID)
Definition tool_call.h:32
std::string arguments_json
Original JSON string (for passthrough dispatch)
Definition tool_call.h:35
std::string name
Tool name (e.g. "filesystem.read_file")
Definition tool_call.h:33
Tool execution interface for the engine.
void(* free_fn)(char *)
Free function for strings returned by history_json.
void * user_data
Opaque pointer (ToolExecutor*)
char *(* history_json)(size_t count, void *user_data)
Optional: return a compact JSON summary of recent tool calls (for validator retry enrichment / diagno...
Engine-level hooks called during tool processing.
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.
One workspace's assembled system prompts (gh#179, v2.14.0).
std::unordered_map< std::string, std::string > tier_prompts
Tier → system prompt.
std::string default_prompt
Untiered first turn.
Typed outcome for POST_TOOL_CALL hook consumers.
UTF-8 validation + replacement at every system boundary where bytes change ownership.