Entropic 2.11.1
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
15
16#include <nlohmann/json.hpp>
17
18#include <algorithm>
19#include <array>
20#include <chrono>
21#include <sstream>
22
23static auto logger = entropic::log::get("core.engine");
24
25namespace entropic {
26
27// ── Hook dispatch helpers ────────────────────────────────
28
37static void fire_hook_info(const HookInterface& hooks,
39 const char* json) {
40 if (hooks.fire_info != nullptr) {
41 hooks.fire_info(hooks.registry, point, json);
42 }
43}
44
52static void fire_loop_start_hook(const HookInterface& hooks,
53 const LoopContext& ctx) {
54 std::string json = "{\"message_count\":"
55 + std::to_string(ctx.messages.size())
56 + ",\"delegation_depth\":"
57 + std::to_string(ctx.delegation_depth) + "}";
58 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_START, json.c_str());
59}
60
68static void fire_loop_end_hook(const HookInterface& hooks,
69 const LoopContext& ctx) {
70 std::string json = "{\"final_state\":\""
71 + std::string(agent_state_name(ctx.state))
72 + "\",\"iterations\":"
73 + std::to_string(ctx.metrics.iterations) + "}";
74 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_END, json.c_str());
75}
76
84static void fire_loop_iteration_hook(const HookInterface& hooks,
85 const LoopContext& ctx) {
86 std::string json = "{\"iteration\":"
87 + std::to_string(ctx.metrics.iterations)
88 + ",\"state\":\"" + agent_state_name(ctx.state)
89 + "\",\"consecutive_errors\":"
90 + std::to_string(ctx.consecutive_errors) + "}";
92}
93
101static void fire_context_assemble_hook(const HookInterface& hooks,
102 const LoopContext& ctx) {
103 std::string json = "{\"message_count\":"
104 + std::to_string(ctx.messages.size()) + "}";
106 json.c_str());
107}
108
109// Forward declaration
110static void remove_anchor_messages(LoopContext& ctx,
111 const std::string& key);
112
119static double now_seconds() {
120 auto now = std::chrono::steady_clock::now();
121 auto dur = now.time_since_epoch();
122 auto ms = std::chrono::duration_cast<
123 std::chrono::milliseconds>(dur);
124 return static_cast<double>(ms.count()) / 1000.0;
125}
126
136 const InferenceInterface& inference,
137 const LoopConfig& loop_config,
138 const CompactionConfig& compaction_config)
139 : inference_(inference),
140 loop_config_(loop_config),
141 token_counter_(loop_config.context_length),
142 compaction_manager_(compaction_config, token_counter_),
143 context_manager_(
144 compaction_manager_, callbacks_,
146 [this](LoopContext& ctx) { reinject_context_anchors(ctx); }
147 }),
148 response_generator_(
149 inference, loop_config, callbacks_,
150 GenerationEvents{&interrupt_flag_, &pause_flag_}) {
151 register_directive_handlers();
152}
153
160void AgentEngine::set_callbacks(const EngineCallbacks& callbacks) {
161 callbacks_ = callbacks;
162}
163
170void AgentEngine::set_tool_executor(
171 const ToolExecutionInterface& tool_exec) {
172 tool_exec_ = tool_exec;
173}
174
181void AgentEngine::set_tier_resolution(
182 const TierResolutionInterface& tier_res) {
183 tier_res_ = tier_res;
184}
185
192void AgentEngine::set_storage(const StorageInterface& storage) {
193 storage_ = storage;
194 compaction_manager_.set_storage(&storage_);
195}
196
203void AgentEngine::set_hooks(const HookInterface& hooks) {
204 hooks_ = hooks;
205 response_generator_.set_hooks(hooks);
206 context_manager_.set_hooks(hooks);
207 directive_processor_.set_hooks(hooks);
208}
209
221void AgentEngine::set_stream_observer(
222 TokenCallback observer, void* user_data) {
223 response_generator_.set_stream_observer(observer, user_data);
224}
225
234void AgentEngine::set_delegation_callbacks(
235 ent_decision_t (*on_start)(const ent_delegation_request_t*, void*),
236 ent_decision_t (*on_complete)(const ent_delegation_result_t*, void*),
237 void* user_data) {
238 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
239 delegation_cb_.start = on_start;
240 delegation_cb_.complete = on_complete;
241 delegation_cb_.user_data = user_data;
242}
243
251AgentEngine::delegation_callbacks_snapshot() const {
252 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
253 return delegation_cb_;
254}
255
263void AgentEngine::set_validation_provider(
264 char* (*provider)(void*), void* user_data) {
265 validation_provider_ = provider;
266 validation_provider_data_ = user_data;
267}
268
275const TierResolutionInterface& AgentEngine::tier_resolution() const {
276 return tier_res_;
277}
278
292void AgentEngine::apply_identity_overrides(LoopContext& ctx) {
293 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
294 return;
295 }
296 auto mi = tier_res_.get_tier_param(
297 ctx.locked_tier, "max_iterations", tier_res_.user_data);
298 if (!mi.empty()) {
299 ctx.effective_max_iterations = std::atoi(mi.c_str());
300 logger->info("[override] tier={} max_iterations={}",
302 }
303 auto mt = tier_res_.get_tier_param(
304 ctx.locked_tier, "max_tool_calls_per_turn", tier_res_.user_data);
305 if (!mt.empty()) {
306 ctx.effective_max_tool_calls_per_turn = std::atoi(mt.c_str());
307 logger->info("[override] tier={} max_tool_calls_per_turn={}",
309 }
310}
311
320int AgentEngine::resolve_max_iterations(const LoopContext& ctx) const {
321 return ctx.effective_max_iterations >= 0
322 ? ctx.effective_max_iterations
323 : loop_config_.max_iterations;
324}
325
333int AgentEngine::resolve_max_tool_calls(const LoopContext& ctx) const {
334 return ctx.effective_max_tool_calls_per_turn >= 0
335 ? ctx.effective_max_tool_calls_per_turn
336 : loop_config_.max_tool_calls_per_turn;
337}
338
348void AgentEngine::run_loop(LoopContext& ctx, bool inherit_interrupt) {
349 // gh#81 (v2.4.3): child delegation loops inherit the parent's
350 // interrupt state — clearing it here would swallow a parent
351 // interrupt raised just before the child was dispatched. Only a
352 // fresh top-level turn resets.
353 if (!inherit_interrupt) {
354 reset_interrupt();
355 pause_flag_.store(false);
356 }
357 apply_identity_overrides(ctx);
358 reinject_context_anchors(ctx);
359 if (ctx.metrics.start_time == 0.0) {
361 }
362 set_state(ctx, AgentState::PLANNING);
363 loop(ctx);
365 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
366 auto& tm = per_tier_metrics_[
367 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
368 tm.iterations += ctx.metrics.iterations;
369 tm.tool_calls += ctx.metrics.tool_calls;
370 tm.tokens_used += ctx.metrics.tokens_used;
371 tm.errors += ctx.metrics.errors;
372 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
373}
374
386std::vector<Message> AgentEngine::run(std::vector<Message> messages,
387 const std::string& tier_override) {
388 // gh#35: update activity timestamp at run() entry so any host
389 // polling `seconds_since_last_activity` for an idle-exit policy
390 // sees a fresh value as soon as work begins.
391 last_activity_epoch_s_.store(
392 std::chrono::duration_cast<std::chrono::seconds>(
393 std::chrono::system_clock::now().time_since_epoch()).count());
394
395 LoopContext ctx;
396 ctx.messages = std::move(messages);
397 ctx.locked_tier = tier_override; // gh#99: "" routes; non-empty locks
399
400 init_session_conversation(ctx);
401
402 reset_interrupt();
403 pause_flag_.store(false);
404
405 reinject_context_anchors(ctx);
406 set_state(ctx, AgentState::PLANNING);
407
408 loop(ctx);
409
411 accumulate_run_metrics(ctx);
412 logger->info("Loop complete: {} iterations, {}ms",
414 return ctx.messages;
415}
416
423void AgentEngine::init_session_conversation(LoopContext& ctx) {
424 // gh#48 (v2.1.12): create the root conversation row when storage
425 // is wired so `ctx.conversation_id` carries a valid FK into
426 // `conversations(id)`. Every downstream delegation copies
427 // `parent_ctx.conversation_id` into `child_ctx.parent_conversation_id`
428 // (`delegation.cpp:387`); without this, the empty string propagates
429 // and every `INSERT INTO delegations` violates the FK silently,
430 // making `entropic.followup` return empty across an entire session.
431 if (storage_.create_conversation == nullptr) { return; }
432 std::string conv_id;
433 if (storage_.create_conversation("session", conv_id, storage_.user_data)
434 && !conv_id.empty()) {
435 ctx.conversation_id = std::move(conv_id);
436 } else {
437 logger->warn("Storage create_conversation failed at run() init; "
438 "delegations will not persist this session (gh#48)");
439 }
440}
441
448void AgentEngine::accumulate_run_metrics(LoopContext& ctx) {
449 last_metrics_ = ctx.metrics; // P2-15: snapshot for entropic_status
450 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
451 auto& tm = per_tier_metrics_[
452 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
453 tm.iterations += ctx.metrics.iterations;
454 tm.tool_calls += ctx.metrics.tool_calls;
455 tm.tokens_used += ctx.metrics.tokens_used;
456 tm.errors += ctx.metrics.errors;
457 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
458}
459
467void AgentEngine::loop(LoopContext& ctx) {
468 fire_loop_start_hook(hooks_, ctx); // ON_LOOP_START (v1.9.1)
469
470 while (!should_stop(ctx)) {
471 ctx.metrics.iterations++;
472
473 if (interrupt_flag_.load()) {
474 set_state(ctx, AgentState::INTERRUPTED);
475 break;
476 }
477
478 execute_iteration(ctx);
479
480 if (ctx.state == AgentState::ERROR) {
481 break;
482 }
483 }
484
485 if (ctx.metrics.iterations >= resolve_max_iterations(ctx)
486 && !is_terminal_state(ctx)) {
487 // P3-18 follow-up (2.0.6-rc16.2): force synthetic completion
488 // so the facade returns a proper result when the cap is hit.
489 // E7 (2.0.6-rc18): mark ctx.metadata with the terminal reason
490 // so delegate result and parent-tier relay can surface that
491 // the child did not complete naturally.
492 logger->warn("Loop ended due to max iterations ({}/{}) — "
493 "forcing synthetic entropic.complete",
494 ctx.metrics.iterations, resolve_max_iterations(ctx));
495 Message forced;
496 forced.role = "assistant";
497 forced.content = "[iteration cap reached after "
498 + std::to_string(ctx.metrics.iterations)
499 + " iterations — returning current state]";
500 ctx.messages.push_back(std::move(forced));
501 ctx.metadata["terminal_reason"] = "budget_exhausted";
502 set_state(ctx, AgentState::COMPLETE);
503 }
504
505 fire_loop_end_hook(hooks_, ctx); // ON_LOOP_END (v1.9.1)
506}
507
515void AgentEngine::execute_iteration(LoopContext& ctx) {
516 logger->info("[LOOP] iter {}/{} state={} msgs={}",
517 ctx.metrics.iterations,
518 resolve_max_iterations(ctx),
519 agent_state_name(ctx.state),
520 ctx.messages.size());
521
522 fire_loop_iteration_hook(hooks_, ctx); // ON_LOOP_ITERATION (v1.9.1)
523
524 context_manager_.refresh_context_limit(ctx, 0);
525 context_manager_.prune_old_tool_results(ctx);
526 context_manager_.check_compaction(ctx);
527
528 fire_context_assemble_hook(hooks_, ctx); // ON_CONTEXT_ASSEMBLE (v1.9.1)
529
530 set_state(ctx, AgentState::EXECUTING);
531
532 // Hook: PRE_GENERATE — can cancel (v1.9.1)
533 if (fire_pre_hook(ENTROPIC_HOOK_PRE_GENERATE, ctx.metrics.iterations)) {
534 set_state(ctx, AgentState::COMPLETE);
535 return;
536 }
537
538 auto result = response_generator_.generate_response(ctx);
539 dispatch_post_generate(ctx, result);
540 process_generation_result(ctx, result);
541}
542
550void AgentEngine::process_generation_result(LoopContext& ctx,
551 GenerateResult& result) {
552 auto [cleaned, tool_calls] = parse_tool_calls(result.content);
553 logger->info("[ITER] finish={}, {} tool call(s), {} chars",
554 result.finish_reason, tool_calls.size(),
555 cleaned.size());
556 Message assistant_msg{"assistant", cleaned};
557 ctx.messages.push_back(std::move(assistant_msg));
558
559 bool made_tool_call =
560 !tool_calls.empty() && tool_exec_.process_tool_calls != nullptr;
561 // gh#84 (v2.5.1): the budget resets on genuine progress, not on a
562 // merely-parsed call — process_tool_results reports whether any
563 // tool actually executed (ok / ok_empty).
564 bool made_progress = false;
565 if (made_tool_call) {
566 made_progress = process_tool_results(ctx, tool_calls);
567 // gh#123: reset retry counter so a genuine tool call clears prior
568 // consecutive-empty-turn debt.
569 if (made_progress) {
570 ctx.metadata["zero_tool_call_retries"] = "0";
571 }
572 } else {
573 evaluate_no_tool_decision(ctx, cleaned, result.finish_reason);
574 }
575
576 // gh#80 (v2.5.0): charge the thinking budget before dispatch so a
577 // budget-triggered terminal is honored by dispatch_pending_or_halt.
578 charge_thinking_budget(ctx, result.content.size(), made_progress);
579
580 dispatch_pending_or_halt(ctx);
581}
582
588void AgentEngine::charge_thinking_budget(
589 LoopContext& ctx, size_t content_len, bool made_tool_call) {
590 if (loop_config_.budget_mode == BudgetMode::off) { return; }
591
592 // A tool call is productive action — reset the window and the
593 // nudge latch so the model earns a fresh thinking allowance.
594 if (made_tool_call) {
595 ctx.budget_tokens_since_tool = 0;
596 ctx.budget_window_start_s = 0.0;
597 ctx.budget_completion_nudged = false;
598 return;
599 }
600
601 int units = budget_units_consumed(ctx, content_len);
602 if (units < loop_config_.budget_limit) { return; }
603
604 if (!ctx.budget_completion_nudged) {
605 nudge_budget_completion(ctx);
606 } else {
607 hard_cut_budget(ctx);
608 }
609}
610
618int AgentEngine::budget_units_consumed(LoopContext& ctx, size_t content_len) {
619 if (loop_config_.budget_mode == BudgetMode::tokens) {
620 // Estimate generated tokens from content length — the
621 // function-pointer inference ABI doesn't thread an exact count
622 // back. ~4 chars/token; content includes <think> blocks, which
623 // is exactly the deliberation we want to charge.
624 constexpr size_t kCharsPerToken = 4;
625 ctx.budget_tokens_since_tool +=
626 static_cast<int>(content_len / kCharsPerToken);
627 return ctx.budget_tokens_since_tool;
628 }
629 // wall_clock
630 if (ctx.budget_window_start_s == 0.0) {
631 ctx.budget_window_start_s = now_seconds();
632 }
633 return static_cast<int>(now_seconds() - ctx.budget_window_start_s);
634}
635
641void AgentEngine::nudge_budget_completion(LoopContext& ctx) {
642 ctx.budget_completion_nudged = true;
643 ctx.budget_tokens_since_tool = 0;
644 ctx.budget_window_start_s =
645 (loop_config_.budget_mode == BudgetMode::wall_clock)
646 ? now_seconds() : 0.0;
647 logger->warn("[BUDGET] thinking budget reached ({} {}); nudging completion",
648 loop_config_.budget_limit,
649 loop_config_.budget_mode == BudgetMode::tokens
650 ? "tokens" : "seconds");
651 Message nudge{"user",
652 "[engine] thinking budget reached — emit entropic.complete now "
653 "with your best current answer. Further deliberation without a "
654 "tool call will be cut off."};
655 ctx.messages.push_back(std::move(nudge));
656}
657
663void AgentEngine::hard_cut_budget(LoopContext& ctx) {
664 logger->warn("[BUDGET] thinking budget exhausted after nudge — "
665 "hard-cutting turn");
666 Message cut{"assistant",
667 "[thinking budget exhausted — the turn was hard-cut after the "
668 "completion nudge went unheeded; no tool call was emitted]"};
669 ctx.messages.push_back(std::move(cut));
670 ctx.metadata["terminal_reason"] = "budget_exhausted_thinking";
671 set_state(ctx, AgentState::COMPLETE);
672}
673
692void AgentEngine::dispatch_pending_or_halt(LoopContext& ctx) {
693 if (interrupt_flag_.load() && !is_terminal_state(ctx)) {
694 logger->info("[ITER] interrupt observed during tool processing — "
695 "halting before pending dispatch");
696 set_state(ctx, AgentState::INTERRUPTED);
697 return;
698 }
699
700 if (is_terminal_state(ctx)) {
701 if (ctx.pending_delegation.has_value()
702 || ctx.pending_pipeline.has_value()) {
703 logger->info("[ITER] terminal state reached during batch "
704 "tool processing — dropping pending {}",
705 ctx.pending_delegation.has_value()
706 ? "delegation" : "pipeline");
707 }
708 return;
709 }
710
711 if (ctx.pending_delegation.has_value()) {
712 ctx.metrics.iterations--;
713 execute_pending_delegation(ctx);
714 } else if (ctx.pending_pipeline.has_value()) {
715 ctx.metrics.iterations--;
716 execute_pending_pipeline(ctx);
717 }
718}
719
728void AgentEngine::evaluate_no_tool_decision(
729 LoopContext& ctx,
730 const std::string& content,
731 const std::string& finish_reason) {
732 if (handle_terminal_finish_reasons(ctx, finish_reason)) { return; }
733 if (try_auto_chain(ctx, finish_reason, content)) {
734 logger->info("[DECISION] auto-chain triggered");
735 return;
736 }
737 if (record_explicit_completion_failure(ctx, finish_reason)) { return; }
738 if (response_generator_.is_response_complete(content, "[]")) {
739 logger->info("[DECISION] response complete");
740 set_state(ctx, AgentState::COMPLETE);
741 }
742}
743
757bool AgentEngine::handle_terminal_finish_reasons(
758 LoopContext& ctx, const std::string& finish_reason) {
759 if (finish_reason == "interrupted") {
760 logger->info("[DECISION] interrupted");
761 set_state(ctx, AgentState::INTERRUPTED);
762 return true;
763 }
764 if (finish_reason == "length") {
765 logger->info("[DECISION] length, continuing");
766 return true;
767 }
768 return false;
769}
770
781bool AgentEngine::record_explicit_completion_failure(
782 LoopContext& ctx, const std::string& finish_reason) {
783 if (!tier_requires_explicit_completion(ctx.locked_tier)) {
784 return false;
785 }
786 auto& retries = ctx.metadata["zero_tool_call_retries"];
787 int n = retries.empty() ? 0 : std::atoi(retries.c_str());
788 // gh#123: default 3; per-tier override via max_consecutive_empty_turns
789 int ceiling = 3;
790 if (tier_res_.get_tier_param != nullptr) {
791 auto cv = tier_res_.get_tier_param(
792 ctx.locked_tier, "max_consecutive_empty_turns",
793 tier_res_.user_data);
794 if (!cv.empty()) { ceiling = std::atoi(cv.c_str()); }
795 }
796 if (n >= ceiling) {
797 // Allowance exhausted — this is a genuine failure.
798 ctx.metadata["failure_reason"] =
799 "zero_tool_calls_with_explicit_completion";
800 ctx.metadata["failure_tier"] = ctx.locked_tier;
801 logger->error("[DECISION] empty-turn allowance exhausted "
802 "(tier={}, n={}/{}), failing turn",
803 ctx.locked_tier, n, ceiling);
804 set_state(ctx, AgentState::ERROR);
805 return true;
806 }
807 // Reasoning turn — nudge toward action, not a failure yet.
808 logger->info("[DECISION] tier '{}' empty turn {}/{} "
809 "(finish_reason={}), nudging toward action",
810 ctx.locked_tier, n + 1, ceiling, finish_reason);
811 std::string tools_hint =
812 "(entropic.complete, entropic.delegate, or entropic.inspect)";
813 if (tier_res_.get_tier_param != nullptr) {
814 auto allowed = tier_res_.get_tier_param(
815 ctx.locked_tier, "allowed_tools", tier_res_.user_data);
816 if (!allowed.empty()) { tools_hint = "(" + allowed + ")"; }
817 }
818 Message correction;
819 correction.role = "user";
820 correction.content =
821 "[SYSTEM] Your previous response contained no tool call. "
822 "You must end every turn with exactly one tool call "
823 + tools_hint + ". Retry.";
824 ctx.messages.push_back(std::move(correction));
825 retries = std::to_string(n + 1);
826 set_state(ctx, AgentState::EXECUTING);
827 return true;
828}
829
842bool AgentEngine::tier_requires_explicit_completion(
843 const std::string& tier) const {
844 if (tier_res_.get_tier_param == nullptr) { return false; }
845 auto val = tier_res_.get_tier_param(
846 tier, "explicit_completion", tier_res_.user_data);
847 return val == "true" || val == "1";
848}
849
863bool AgentEngine::is_delegation_cycle(
864 const LoopContext& ctx, const std::string& target) const {
865 if (target == ctx.locked_tier) { return true; }
866 for (const auto& anc : ctx.delegation_ancestor_tiers) {
867 if (anc == target) { return true; }
868 }
869 return false;
870}
871
879bool AgentEngine::is_delegation_repeat_blocked(
880 const LoopContext& ctx, const std::string& target) const {
881 return target == ctx.last_failed_delegation_target
883 >= loop_config_.max_consecutive_failed_delegations;
884}
885
894bool AgentEngine::fold_complete_into_assistant(
895 LoopContext& ctx, const Message& tool_result_msg) const {
896 auto tn_it = tool_result_msg.metadata.find("tool_name");
897 bool is_complete = tn_it != tool_result_msg.metadata.end()
898 && tn_it->second == "entropic.complete";
899 if (!is_complete || ctx.messages.empty()) { return false; }
900 auto& last = ctx.messages.back();
901 auto sum_it = ctx.metadata.find("explicit_completion_summary");
902 bool foldable = last.role == "assistant" && last.content.empty()
903 && sum_it != ctx.metadata.end();
904 if (!foldable) { return false; }
905 last.content = sum_it->second;
906 return true;
907}
908
914int64_t AgentEngine::seconds_since_last_activity() const {
915 auto last = last_activity_epoch_s_.load();
916 if (last == 0) { return 0; }
917 auto now = std::chrono::duration_cast<std::chrono::seconds>(
918 std::chrono::system_clock::now().time_since_epoch()).count();
919 return now > last ? (now - last) : 0;
920}
921
929bool AgentEngine::should_stop(const LoopContext& ctx) const {
930 bool at_limit = ctx.metrics.iterations >= resolve_max_iterations(ctx)
932 return is_terminal_state(ctx) || at_limit;
933}
934
949bool AgentEngine::is_terminal_state(const LoopContext& ctx) {
950 return ctx.state == AgentState::COMPLETE
951 || ctx.state == AgentState::ERROR
952 || ctx.state == AgentState::INTERRUPTED;
953}
954
963void AgentEngine::set_state(LoopContext& ctx, AgentState state) {
964 auto prev = ctx.state;
965 ctx.state = state;
966 logger->info("State: {}", agent_state_name(state));
967 if (callbacks_.on_state_change != nullptr) {
968 callbacks_.on_state_change(
969 static_cast<int>(state), callbacks_.user_data);
970 }
971 // Persistent slot (gh#40 fallout, v2.1.10): the streaming entry
972 // points overwrite callbacks_ via set_callbacks(), so the legacy
973 // on_state_change is silent for streaming runs. The persistent
974 // slot is unaffected by that shuffle.
975 if (state_observer_ != nullptr) {
976 state_observer_(static_cast<int>(state), state_observer_data_);
977 }
978
979 // Hook: ON_STATE_CHANGE (v1.9.1)
980 {
981 std::string json = "{\"previous\":\""
982 + std::string(agent_state_name(prev))
983 + "\",\"current\":\""
984 + std::string(agent_state_name(state)) + "\"}";
985 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_STATE_CHANGE, json.c_str());
986 }
987
988 // Hook: ON_ERROR when entering ERROR state (v1.9.1)
989 if (state == AgentState::ERROR) {
990 std::string json = "{\"error_code\":\"STATE_ERROR\""
991 ",\"iteration\":"
992 + std::to_string(ctx.metrics.iterations)
993 + ",\"consecutive\":" + std::to_string(ctx.consecutive_errors)
994 + "}";
995 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_ERROR, json.c_str());
996 }
997}
998
1010void AgentEngine::interrupt() {
1011 if (!interrupt_flag_.exchange(true)) {
1012 logger->info("Engine interrupted");
1013 // P1-10: propagate to external MCP transports so in-flight
1014 // tool calls abort alongside the generation loop.
1015 if (external_interrupt_cb_ != nullptr) {
1016 external_interrupt_cb_(external_interrupt_data_);
1017 }
1018 }
1019 pause_flag_.store(false);
1020}
1021
1034void AgentEngine::set_external_interrupt(void (*cb)(void*),
1035 void* user_data) {
1036 external_interrupt_cb_ = cb;
1037 external_interrupt_data_ = user_data;
1038}
1039
1045void AgentEngine::reset_interrupt() {
1046 interrupt_flag_.store(false);
1047}
1048
1054void AgentEngine::pause() {
1055 logger->info("Engine paused");
1056 pause_flag_.store(true);
1057}
1058
1064void AgentEngine::cancel_pause() {
1065 logger->info("Pause cancelled, interrupting");
1066 pause_flag_.store(false);
1067 interrupt_flag_.store(true);
1068}
1069
1077std::pair<int, int> AgentEngine::context_usage(
1078 const std::vector<Message>& messages) const {
1079 int used = token_counter_.count_messages(messages);
1080 return {used, token_counter_.max_tokens};
1081}
1082
1089void AgentEngine::reinject_context_anchors(LoopContext& ctx) {
1090 for (const auto& [key, content] : context_anchors_) {
1091 ContextAnchorDirective d(key, content);
1093 dir_anchor(ctx, d, r);
1094 }
1095}
1096
1097// ── Directive handler registration ───────────────────────
1098
1104void AgentEngine::register_directive_handlers() {
1105 auto reg = [this](entropic_directive_type_t t, auto fn) {
1106 directive_processor_.register_handler(t,
1107 [this, fn](LoopContext& c, const Directive& d,
1108 DirectiveResult& r) {
1109 (this->*fn)(c, d, r);
1110 });
1111 };
1112 reg(ENTROPIC_DIRECTIVE_STOP_PROCESSING, &AgentEngine::dir_stop);
1113 reg(ENTROPIC_DIRECTIVE_TIER_CHANGE, &AgentEngine::dir_tier_change);
1114 reg(ENTROPIC_DIRECTIVE_DELEGATE, &AgentEngine::dir_delegate);
1115 reg(ENTROPIC_DIRECTIVE_PIPELINE, &AgentEngine::dir_pipeline);
1116 reg(ENTROPIC_DIRECTIVE_COMPLETE, &AgentEngine::dir_complete);
1117 reg(ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS,&AgentEngine::dir_clear_todos);
1118 reg(ENTROPIC_DIRECTIVE_INJECT_CONTEXT, &AgentEngine::dir_inject);
1119 reg(ENTROPIC_DIRECTIVE_PRUNE_MESSAGES, &AgentEngine::dir_prune);
1120 reg(ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR, &AgentEngine::dir_anchor);
1121 reg(ENTROPIC_DIRECTIVE_PHASE_CHANGE, &AgentEngine::dir_phase);
1122 reg(ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER,&AgentEngine::dir_notify);
1123}
1124
1125// ── Directive handlers ───────────────────────────────────
1126
1132void AgentEngine::dir_stop(
1133 LoopContext&, const Directive&, DirectiveResult& r) {
1134 logger->info("[DIRECTIVE] stop_processing");
1135 r.stop_processing = true;
1136}
1137
1143void AgentEngine::dir_tier_change(
1144 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1145 const auto& tc = static_cast<const TierChangeDirective&>(d);
1146 ctx.locked_tier = tc.tier;
1147 r.tier_changed = true;
1148 logger->info("[DIRECTIVE] tier_change: {}", tc.tier);
1149}
1150
1156void AgentEngine::dir_delegate(
1157 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1158 const auto& dl = static_cast<const DelegateDirective&>(d);
1159 ctx.pending_delegation = PendingDelegation{
1160 dl.target, dl.task, dl.max_turns,
1161 dl.resume_from_delegation_id};
1162 r.stop_processing = true;
1163 if (dl.resume_from_delegation_id.empty()) {
1164 logger->info("[DIRECTIVE] delegate: target={} task='{}'",
1165 dl.target, dl.task);
1166 } else {
1167 // gh#32 (v2.1.6): target will be resolved from storage later.
1168 logger->info("[DIRECTIVE] resume_delegation: id={} task='{}'",
1169 dl.resume_from_delegation_id, dl.task);
1170 }
1171}
1172
1178void AgentEngine::dir_pipeline(
1179 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1180 const auto& pl = static_cast<const PipelineDirective&>(d);
1181 ctx.pending_pipeline = PendingPipeline{pl.stages, pl.task};
1182 r.stop_processing = true;
1183 logger->info("[DIRECTIVE] pipeline: {} stages", pl.stages.size());
1184}
1185
1197void AgentEngine::dir_complete(
1198 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1199 const auto& cd = static_cast<const CompleteDirective&>(d);
1200
1201 // P1-5 follow-up (2.0.6-rc16.2): signal validating phase so async
1202 // subscribers can progress queued/running/running:<tier> → validating.
1203 auto prior_state = ctx.state;
1204 set_state(ctx, AgentState::VERIFYING);
1205
1206 // Fire ON_COMPLETE pre-hook — application can validate/reject
1207 if (fire_complete_hook(cd.summary, ctx)) {
1208 logger->info("[DIRECTIVE] complete REJECTED by hook → revising");
1209 // Revision path: inform subscribers the validator is rewriting.
1210 ctx.metadata["validator_phase"] = "revising";
1211 set_state(ctx, prior_state);
1212 r.stop_processing = false; // don't stop — model should retry
1213 return;
1214 }
1215
1216 ctx.metadata["explicit_completion_summary"] = cd.summary;
1217 // Issue #10 (v2.1.4): persist coverage_gap signal in metadata so
1218 // delegation.cpp::build_child_result can hoist it onto the
1219 // DelegationResult that flows back to the parent. The metadata
1220 // path is the only channel that survives between the child loop
1221 // (which sees the directive) and the parent's
1222 // finalize_delegation_result (which reads the result).
1223 if (cd.coverage_gap) {
1224 ctx.metadata["coverage_gap"] = "true";
1225 ctx.metadata["gap_description"] = cd.gap_description;
1226 nlohmann::json files_arr = cd.suggested_files;
1227 ctx.metadata["suggested_files_json"] = files_arr.dump();
1228 }
1229 set_state(ctx, AgentState::COMPLETE);
1230 r.stop_processing = true;
1231 logger->info("[DIRECTIVE] complete coverage_gap={}",
1232 cd.coverage_gap);
1233}
1234
1240void AgentEngine::dir_clear_todos(
1241 LoopContext&, const Directive&, DirectiveResult&) {
1242 logger->debug("[DIRECTIVE] clear_self_todos (no-op)");
1243}
1244
1250void AgentEngine::dir_inject(
1251 LoopContext&, const Directive& d, DirectiveResult& r) {
1252 const auto& ic = static_cast<const InjectContextDirective&>(d);
1253 if (!ic.content.empty()) {
1254 Message msg;
1255 msg.role = ic.role;
1256 msg.content = ic.content;
1257 r.injected_messages.push_back(std::move(msg));
1258 logger->info("[DIRECTIVE] inject_context");
1259 }
1260}
1261
1267void AgentEngine::dir_prune(
1268 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1269 const auto& pm = static_cast<const PruneMessagesDirective&>(d);
1270 auto [pruned, freed] = context_manager_.prune_tool_results(
1271 ctx, pm.keep_recent);
1272 logger->info("[DIRECTIVE] prune: {} results, {} chars", pruned, freed);
1273}
1274
1280void AgentEngine::dir_anchor(
1281 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1282 const auto& ca = static_cast<const ContextAnchorDirective&>(d);
1283 if (ca.content.empty()) {
1284 context_anchors_.erase(ca.key);
1285 remove_anchor_messages(ctx, ca.key);
1286 logger->info("Removed anchor: {}", ca.key);
1287 return;
1288 }
1289 context_anchors_[ca.key] = ca.content;
1290 remove_anchor_messages(ctx, ca.key);
1291 Message anchor;
1292 anchor.role = "user";
1293 anchor.content = ca.content;
1294 anchor.metadata["is_context_anchor"] = "true";
1295 anchor.metadata["anchor_key"] = ca.key;
1296 ctx.messages.push_back(std::move(anchor));
1297 logger->info("Updated anchor: {}", ca.key);
1298}
1299
1305void AgentEngine::dir_phase(
1306 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1307 const auto& pc = static_cast<const PhaseChangeDirective&>(d);
1308 ctx.active_phase = pc.phase;
1309 logger->info("[DIRECTIVE] phase_change: {}", pc.phase);
1310}
1311
1317void AgentEngine::dir_notify(
1318 LoopContext&, const Directive& d, DirectiveResult&) {
1319 const auto& np = static_cast<const NotifyPresenterDirective&>(d);
1320 if (callbacks_.on_presenter_notify != nullptr) {
1321 callbacks_.on_presenter_notify(
1322 np.key.c_str(), np.data_json.c_str(),
1323 callbacks_.user_data);
1324 }
1325}
1326
1327// ── Tool call parsing (v1.8.5) ───────────────────────────
1328
1345 const nlohmann::json& obj, const std::string& tc_str) {
1346 ToolCall tc;
1347 tc.name = obj.value("name", "");
1348 tc.id = "tc-" + std::to_string(
1349 std::hash<std::string>{}(tc.name + tc_str) & 0xFFFF);
1350 if (obj.contains("arguments") && obj["arguments"].is_object()) {
1351 tc.arguments_json = obj["arguments"].dump();
1352 for (auto& [k, v] : obj["arguments"].items()) {
1353 tc.arguments[k] = v.is_string()
1354 ? v.get<std::string>() : v.dump();
1355 }
1356 }
1357 return tc;
1358}
1359
1367static std::vector<ToolCall> decode_tool_calls_json(
1368 const std::string& tc_str) {
1369 std::vector<ToolCall> calls;
1370 if (tc_str == "[]" || tc_str.empty()) { return calls; }
1371 auto arr = nlohmann::json::parse(tc_str, nullptr, false);
1372 if (!arr.is_array()) { return calls; }
1373 for (const auto& obj : arr) {
1374 calls.push_back(build_tool_call_from_json(obj, tc_str));
1375 }
1376 return calls;
1377}
1378
1392std::pair<std::string, std::vector<ToolCall>>
1393AgentEngine::parse_tool_calls(const std::string& raw_content) {
1394 if (inference_.parse_tool_calls == nullptr) {
1395 return {raw_content, {}};
1396 }
1397
1398 char* cleaned = nullptr;
1399 char* tc_json = nullptr;
1400 int rc = inference_.parse_tool_calls(
1401 raw_content.c_str(), &cleaned, &tc_json,
1402 inference_.adapter_data);
1403
1404 // gh#111 (v2.9.8): the tool-call parse callback is a SEPARATE inbound
1405 // boundary from response_generator's content sanitize. The backend derives
1406 // both `*cleaned` and `*tool_calls_json` from its own parse of the RAW
1407 // generation (common_chat / adapter parse_response), so a split multi-byte
1408 // UTF-8 codepoint — routine under MTP speculative decode — arrives here as
1409 // invalid bytes EVEN THOUGH result.content was already sanitized upstream
1410 // (response_generator.cpp:470). Both outputs cross into engine-owned state
1411 // at this one function:
1412 // cleaned_str -> assistant Message.content -> (delegation) extract_summary
1413 // fallback -> DelegationResult.summary
1414 // -> fire_delegate_complete_hook j.dump() [the gh#111 throw]
1415 // tc_str -> decode_tool_calls_json -> build_tool_call_from_json
1416 // obj["arguments"].dump() + CompleteTool result.dump()
1417 // v2.9.7 patched only the plugin-revised (out!=null) hook branches, which
1418 // never run on the headless path — so the raw summary still reached the
1419 // dump. Sanitize BOTH channels at this single boundary (the tool-call-channel
1420 // sibling of the content sanitize) so no downstream nlohmann::json::dump()
1421 // can throw type_error.316. (gh#3 recurrence.)
1422 std::string cleaned_str =
1423 mcp::sanitize_utf8(cleaned ? cleaned : raw_content);
1424 std::string tc_str = mcp::sanitize_utf8(tc_json ? tc_json : "[]");
1425
1426 if (inference_.free_fn != nullptr) {
1427 if (cleaned != nullptr) { inference_.free_fn(cleaned); }
1428 if (tc_json != nullptr) { inference_.free_fn(tc_json); }
1429 }
1430
1431 if (rc != 0) { return {cleaned_str, {}}; }
1432 auto calls = decode_tool_calls_json(tc_str);
1433 if (!calls.empty()) {
1434 logger->info("Parsed tool calls from model output");
1435 }
1436 return {cleaned_str, std::move(calls)};
1437}
1438
1444void AgentEngine::defang_meta_action_envelope(Message& msg) const {
1445 auto tn = msg.metadata.find("tool_name");
1446 bool is_meta = tn != msg.metadata.end()
1447 && tn->second.rfind("entropic.", 0) == 0;
1448 if (!is_meta) { return; }
1449 auto j = nlohmann::json::parse(msg.content, nullptr, false);
1450 bool is_envelope = j.is_object() && j.contains("action")
1451 && j["action"].is_string();
1452 if (!is_envelope) { return; }
1453 msg.content = "(engine: " + j["action"].get<std::string>()
1454 + " directive accepted)";
1455}
1456
1466bool AgentEngine::process_tool_results(
1467 LoopContext& ctx,
1468 const std::vector<ToolCall>& tool_calls) {
1469 set_state(ctx, AgentState::WAITING_TOOL);
1470
1471 auto results = tool_exec_.process_tool_calls(
1472 ctx, tool_calls, tool_exec_.user_data);
1473
1474 logger->info("[TOOLS] {} call(s) -> {} result message(s)",
1475 tool_calls.size(), results.size());
1476
1477 // gh#84 (v2.5.1): "progress" = at least one tool genuinely
1478 // executed (result_kind ok / ok_empty), not merely parsed. The
1479 // thinking budget resets only on progress so a duplicate/rejected
1480 // -spam spiral (rejected_duplicate, rejected_schema, anti-spiral)
1481 // can no longer keep its budget perpetually fresh.
1482 bool made_progress = false;
1483 for (auto& msg : results) {
1484 auto it = msg.metadata.find("result_kind");
1485 if (it != msg.metadata.end()
1486 && (it->second == "ok" || it->second == "ok_empty")) {
1487 made_progress = true;
1488 }
1489 if (fold_complete_into_assistant(ctx, msg)) {
1490 continue; // gh#68: folded; skip pushing JSON user message
1491 }
1492 defang_meta_action_envelope(msg); // gh#88: strip call-shaped envelope
1493 ctx.messages.push_back(std::move(msg));
1494 }
1495
1496 ctx.has_pending_tool_results = true;
1497 // Don't overwrite terminal states set by directive handlers
1498 if (!is_terminal_state(ctx)) {
1499 set_state(ctx, AgentState::EXECUTING);
1500 }
1501 return made_progress;
1502}
1503
1504// ── Anchor helper ────────────────────────────────────────
1505
1514 const std::string& key) {
1515 auto& msgs = ctx.messages;
1516 msgs.erase(
1517 std::remove_if(msgs.begin(), msgs.end(),
1518 [&key](const Message& m) {
1519 auto it = m.metadata.find("anchor_key");
1520 return it != m.metadata.end() && it->second == key;
1521 }),
1522 msgs.end());
1523}
1524
1525// ── Hook helpers (v1.9.1) ────────────────────────────────
1526
1536bool AgentEngine::fire_pre_hook(
1537 entropic_hook_point_t point, int iteration) {
1538 if (hooks_.fire_pre == nullptr) {
1539 return false;
1540 }
1541 std::string json = "{\"iteration\":"
1542 + std::to_string(iteration) + "}";
1543 char* modified = nullptr;
1544 int rc = hooks_.fire_pre(hooks_.registry,
1545 point, json.c_str(), &modified);
1546 free(modified);
1547 return rc != 0;
1548}
1549
1560static const std::array<const char*, 256>& json_escape_table() {
1561 static const auto tbl = []() {
1562 std::array<const char*, 256> t{};
1563 t[static_cast<unsigned char>('"')] = "\\\"";
1564 t[static_cast<unsigned char>('\\')] = "\\\\";
1565 t[static_cast<unsigned char>('\n')] = "\\n";
1566 t[static_cast<unsigned char>('\r')] = "\\r";
1567 t[static_cast<unsigned char>('\t')] = "\\t";
1568 return t;
1569 }();
1570 return tbl;
1571}
1572
1580static std::string json_escape_engine(const std::string& s) {
1581 const auto& tbl = json_escape_table();
1582 std::string out;
1583 out.reserve(s.size() + 16);
1584 for (char c : s) {
1585 const char* esc = tbl[static_cast<unsigned char>(c)];
1586 if (esc) { out += esc; }
1587 else { out += c; }
1588 }
1589 return out;
1590}
1591
1603static std::string build_tool_manifest(
1604 const std::vector<Message>& messages) {
1605 std::string manifest;
1606 for (const auto& msg : messages) {
1607 auto it = msg.metadata.find("tool_name");
1608 if (it == msg.metadata.end() || it->second.empty()) {
1609 continue;
1610 }
1611 // Issue #5 (v2.1.3): when prune has stubbed this message, the
1612 // original (pre-stub) content size is preserved in
1613 // metadata["original_content"]. Report the real size so the
1614 // validator sees accurate evidence sizes instead of the stub
1615 // length (which is always ~50 chars regardless of original).
1616 auto orig = msg.metadata.find("original_content");
1617 size_t real_size = (orig != msg.metadata.end())
1618 ? orig->second.size()
1619 : msg.content.size();
1620 manifest += "- " + it->second
1621 + " \xe2\x86\x92 " + std::to_string(real_size)
1622 + " chars\n";
1623 }
1624 return manifest;
1625}
1626
1635static std::string format_tool_evidence_entry(const Message& msg,
1636 size_t chars_per_result) {
1637 auto orig = msg.metadata.find("original_content");
1638 const std::string& src = (orig != msg.metadata.end())
1639 ? orig->second
1640 : msg.content;
1641 auto iter = msg.metadata.find("added_at_iteration");
1642 std::string out = "## ";
1643 out += msg.metadata.at("tool_name");
1644 if (iter != msg.metadata.end()) {
1645 out += " [iter " + iter->second + "]";
1646 }
1647 out += "\n";
1648 if (src.size() <= chars_per_result) {
1649 out += src;
1650 } else {
1651 out += src.substr(0, chars_per_result);
1652 out += "\n[... truncated, "
1653 + std::to_string(src.size() - chars_per_result)
1654 + " more chars]";
1655 }
1656 out += "\n\n";
1657 return out;
1658}
1659
1687static std::string build_tool_evidence(
1688 const std::vector<Message>& messages,
1689 size_t max_results,
1690 size_t chars_per_result) {
1691 std::vector<const Message*> tool_msgs;
1692 for (const auto& msg : messages) {
1693 auto it = msg.metadata.find("tool_name");
1694 if (it == msg.metadata.end() || it->second.empty()) {
1695 continue;
1696 }
1697 tool_msgs.push_back(&msg);
1698 }
1699 if (tool_msgs.empty()) { return {}; }
1700
1701 size_t elided = (tool_msgs.size() > max_results)
1702 ? (tool_msgs.size() - max_results) : 0;
1703 size_t start = elided;
1704 std::string out;
1705 if (elided > 0) {
1706 out += "(" + std::to_string(elided)
1707 + " earlier tool results elided for length)\n";
1708 }
1709 for (size_t i = start; i < tool_msgs.size(); ++i) {
1710 out += format_tool_evidence_entry(*tool_msgs[i], chars_per_result);
1711 }
1712 return out;
1713}
1714
1730 const std::string& base,
1731 const ToolExecutionInterface& tool_exec) {
1732 if (tool_exec.history_json == nullptr) { return base; }
1733 char* js = tool_exec.history_json(20, tool_exec.user_data);
1734 if (js == nullptr) { return base; }
1735 std::string out = base + "prior-iteration history: " + js + "\n";
1736 if (tool_exec.free_fn != nullptr) { tool_exec.free_fn(js); }
1737 return out;
1738}
1739
1747static std::string extract_system_prompt(
1748 const std::vector<Message>& messages) {
1749 for (const auto& msg : messages) {
1750 if (msg.role == "system") { return msg.content; }
1751 }
1752 return {};
1753}
1754
1770void AgentEngine::fire_post_generate_hook(
1771 GenerateResult& result,
1772 const std::string& tier,
1773 const std::vector<Message>& messages) {
1774 if (hooks_.fire_post == nullptr) {
1775 return;
1776 }
1777 auto manifest = build_tool_manifest(messages);
1778 manifest = enrich_manifest_with_history(manifest, tool_exec_);
1779 auto sys = extract_system_prompt(messages);
1780 // Issue #5 (v2.1.3): include un-pruned tool-result evidence so the
1781 // constitutional validator can verify file:line citations against
1782 // actual content rather than the stubs that prune leaves behind.
1783 // Bounded at 20 most-recent results × 800 chars each (~16KB max
1784 // evidence), well within any sane critique-prompt budget. The
1785 // field is OPTIONAL on the hook contract — pre-2.1.3 consumers
1786 // ignore it; v2.1.3 validator augments its critique prompt.
1787 auto evidence = build_tool_evidence(messages, 20, 800);
1788 std::string json =
1789 "{\"finish_reason\":\"" + result.finish_reason
1790 + "\",\"content\":\"" + json_escape_engine(result.content)
1791 + "\",\"tier\":\"" + tier
1792 + "\",\"tool_context\":\"" + json_escape_engine(manifest)
1793 + "\",\"tool_evidence\":\"" + json_escape_engine(evidence)
1794 + "\",\"system_prompt\":\"" + json_escape_engine(sys) + "\"}";
1795 char* out = nullptr;
1796 hooks_.fire_post(hooks_.registry,
1797 ENTROPIC_HOOK_POST_GENERATE, json.c_str(), &out);
1798 if (out != nullptr) {
1799 // gh#3 recurrence (v2.9.6): a hook's revised content is an
1800 // inbound boundary crossing a plugin .so, same class as the
1801 // MCP tool-result boundary — sanitize before it re-enters the
1802 // engine so downstream json::dump() (delegate-complete hook,
1803 // facade serialization, storage) cannot throw type_error 316.
1804 result.content = mcp::sanitize_utf8(out);
1805 logger->info("POST_GENERATE hook revised content");
1806 free(out);
1807 }
1808}
1809
1831void AgentEngine::dispatch_post_generate(
1832 LoopContext& ctx, GenerateResult& result) {
1833 // The per-turn reminders built from pending_validation_feedback
1834 // and pending_anti_spiral_warning were just consumed by
1835 // generate_response — clear both so neither leaks into a later
1836 // iteration. (#42, #45 — both one-shot.)
1837 ctx.pending_validation_feedback.clear();
1838 ctx.pending_anti_spiral_warning.clear();
1839 fire_post_generate_hook(result, ctx.locked_tier, ctx.messages);
1840 // After POST_GENERATE the validator may have rejected — stash
1841 // its reason for the NEXT iteration's system prompt.
1842 capture_validation_feedback(ctx);
1843}
1844
1850void AgentEngine::capture_validation_feedback(LoopContext& ctx) {
1851 if (validation_provider_ == nullptr) { return; }
1852 char* v = validation_provider_(validation_provider_data_);
1853 if (v == nullptr) { return; }
1854 std::string raw(v);
1855 free(v);
1856 try {
1857 auto j = nlohmann::json::parse(raw);
1858 auto verdict = j.value("verdict", "");
1859 if (verdict.rfind("rejected", 0) != 0) { return; }
1860 std::string joined;
1861 if (j.contains("violations") && j["violations"].is_array()) {
1862 for (const auto& vio : j["violations"]) {
1863 if (!joined.empty()) { joined += "; "; }
1864 joined += vio.is_string()
1865 ? vio.get<std::string>()
1866 : vio.dump();
1867 }
1868 }
1869 if (joined.empty()) { joined = verdict; }
1870 ctx.pending_validation_feedback = joined;
1871 } catch (const nlohmann::json::exception&) {
1872 // Malformed provider output is non-fatal; just skip.
1873 }
1874}
1875
1887static std::string build_tool_results_json(
1888 const std::vector<Message>& messages) {
1889 std::string arr = "[";
1890 bool first = true;
1891 for (const auto& msg : messages) {
1892 auto it = msg.metadata.find("tool_name");
1893 if (it == msg.metadata.end() || it->second.empty()) {
1894 continue;
1895 }
1896 if (!first) { arr += ","; }
1897 arr += "{\"name\":\"" + json_escape_engine(it->second)
1898 + "\",\"content\":\"" + json_escape_engine(msg.content)
1899 + "\"}";
1900 first = false;
1901 }
1902 arr += "]";
1903 return arr;
1904}
1905
1926bool AgentEngine::fire_complete_hook(
1927 const std::string& summary,
1928 const LoopContext& ctx) {
1929 if (hooks_.fire_pre == nullptr) { return false; }
1930
1931 auto tool_results = build_tool_results_json(ctx.messages);
1932 // E3 (2.0.6-rc17): splice validator verdict/violations so
1933 // ON_COMPLETE consumers can distinguish clean pass from
1934 // reverted-for-length / max-revisions-exhausted.
1935 std::string validation_block = ",\"validation\":null";
1936 if (validation_provider_ != nullptr) {
1937 char* v = validation_provider_(validation_provider_data_);
1938 if (v != nullptr) {
1939 validation_block = ",\"validation\":" + std::string(v);
1940 free(v);
1941 }
1942 }
1943 std::string json =
1944 "{\"summary\":\"" + json_escape_engine(summary)
1945 + "\",\"tier\":\"" + ctx.locked_tier
1946 + "\",\"tool_results\":" + tool_results
1947 + ",\"iteration\":" + std::to_string(ctx.metrics.iterations)
1948 + validation_block
1949 + "}";
1950 char* modified = nullptr;
1951 int rc = hooks_.fire_pre(hooks_.registry,
1952 ENTROPIC_HOOK_ON_COMPLETE, json.c_str(), &modified);
1953 if (modified != nullptr) {
1954 // Hook provided rejection feedback — inject as user message.
1955 // Same inbound-boundary gap as fire_post_generate_hook above:
1956 // sanitize the hook's returned bytes before they enter a
1957 // Message that later flows to json::dump() call sites.
1958 Message feedback;
1959 feedback.role = "user";
1960 feedback.content =
1961 std::string("[CITATION VALIDATION] ") + mcp::sanitize_utf8(modified);
1962 const_cast<LoopContext&>(ctx).messages.push_back(
1963 std::move(feedback));
1964 free(modified);
1965 }
1966 return rc != 0;
1967}
1968
1978bool AgentEngine::fire_delegate_pre_hook(
1979 const PendingDelegation& pending, int depth) {
1980 if (hooks_.fire_pre == nullptr) {
1981 return false;
1982 }
1983 std::string json = "{\"target_tier\":\""
1984 + pending.target + "\",\"task\":\""
1985 + pending.task + "\",\"depth\":"
1986 + std::to_string(depth) + "}";
1987 char* modified = nullptr;
1988 int rc = hooks_.fire_pre(hooks_.registry,
1989 ENTROPIC_HOOK_ON_DELEGATE, json.c_str(), &modified);
1990 free(modified);
1991 return rc != 0;
1992}
1993
1994// ── detail::build_delegate_complete_json (gh#113, v2.9.9) ──────────────────
1995// Extracted from fire_delegate_complete_hook so it can be driven directly in
1996// unit tests with a bad-UTF8 summary (simulating gh#112 storage-load path).
1997
2013 const std::string& target,
2014 bool success,
2015 const std::string& summary) {
2016 nlohmann::json j;
2017 j["target_tier"] = target;
2018 j["success"] = success;
2019 j["result_kind"] = result_kind_to_string(success
2020 ? ToolResultKind::ok
2022 j["summary"] = mcp::sanitize_utf8(summary);
2023 return j.dump();
2024}
2025
2041void AgentEngine::fire_delegate_complete_hook(
2042 const std::string& target, bool success,
2043 const std::string& summary) {
2044 if (hooks_.fire_post == nullptr) {
2045 return;
2046 }
2047 std::string json = detail::build_delegate_complete_json(
2048 target, success, summary);
2049 char* out = nullptr;
2050 hooks_.fire_post(hooks_.registry,
2051 ENTROPIC_HOOK_ON_DELEGATE_COMPLETE, json.c_str(), &out);
2052 free(out);
2053}
2054
2055// ── Delegation execution (v1.8.6) ────────────────────────
2056
2064static void run_child_loop_trampoline(LoopContext& ctx, void* user_data) {
2065 auto* engine = static_cast<AgentEngine*>(user_data);
2066 // gh#81 (v2.4.3): inherit the parent's interrupt — a child loop
2067 // must not clear a parent interrupt raised at/just-before dispatch.
2068 engine->run_loop(ctx, /*inherit_interrupt=*/true);
2069}
2070
2078 Message reject;
2079 reject.role = "user";
2080 reject.content = "[DELEGATION REJECTED] Maximum delegation "
2081 "depth (" + std::to_string(
2082 AgentEngine::MAX_DELEGATION_DEPTH) +
2083 ") reached.";
2084 ctx.messages.push_back(std::move(reject));
2085}
2086
2098 LoopContext& ctx, const std::string& target) {
2099 std::string chain;
2100 for (const auto& anc : ctx.delegation_ancestor_tiers) {
2101 chain += anc + " -> ";
2102 }
2103 chain += ctx.locked_tier + " -> " + target;
2104
2105 Message reject;
2106 reject.role = "user";
2107 reject.content = "[DELEGATION REJECTED] Circular delegation "
2108 "detected: tier '" + target +
2109 "' already in the active delegation chain "
2110 "(" + chain + "). Choose a different target.";
2111 ctx.metadata["failure_reason"] = "delegation_cycle";
2112 ctx.metadata["failure_target"] = target;
2113 ctx.messages.push_back(std::move(reject));
2114}
2115
2125 const std::string& target, const DelegationResult& result) {
2126 std::string tag = result.success ? "COMPLETE" : "FAILED";
2127 Message msg;
2128 msg.role = "user";
2129 msg.content = "[DELEGATION " + tag + ": " + target + "] " + result.summary;
2130 ctx.messages.push_back(std::move(msg));
2131}
2132
2145 LoopContext& ctx, const std::string& target, int n) {
2146 Message reject;
2147 reject.role = "user";
2148 reject.content = "[DELEGATION REJECTED] '" + target
2149 + "' has just failed " + std::to_string(n)
2150 + " times in a row. Stop retrying this target. Either "
2151 "respond to the user with what you have, or delegate to "
2152 "a different tier.";
2153 ctx.metadata["failure_reason"] = "delegation_repeat_blocked";
2154 ctx.metadata["failure_target"] = target;
2155 ctx.messages.push_back(std::move(reject));
2156}
2157
2166bool AgentEngine::reject_delegation_if_guarded(
2167 LoopContext& ctx, const PendingDelegation& pending) {
2168 bool rejected = true;
2169 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2170 logger->warn("Delegation rejected: depth {} >= max {}",
2171 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2172 push_delegation_rejected(ctx);
2173 } else if (is_delegation_cycle(ctx, pending.target)) {
2174 // P1-9: reject A→B→A and longer cycles before running a child.
2175 logger->warn("Delegation rejected: cycle on target tier '{}'",
2176 pending.target);
2177 push_delegation_cycle_rejected(ctx, pending.target);
2178 } else if (is_delegation_repeat_blocked(ctx, pending.target)) {
2179 // gh#64: refuse re-delegation to a target that has just failed
2180 // N times in a row. Pre-fix, a lead retrying a Q4 specialist
2181 // that couldn't emit a tool call would burn 30+ identical
2182 // delegations before its own iteration cap stopped it.
2183 logger->warn("Delegation rejected: '{}' failed {}x in a row "
2184 "(>= max_consecutive_failed_delegations={})",
2185 pending.target,
2186 ctx.consecutive_failed_delegations,
2187 loop_config_.max_consecutive_failed_delegations);
2189 ctx, pending.target, ctx.consecutive_failed_delegations);
2190 } else {
2191 rejected = false;
2192 }
2193 return rejected;
2194}
2195
2209void AgentEngine::execute_pending_delegation(LoopContext& ctx) {
2210 auto pending = std::move(*ctx.pending_delegation);
2211 ctx.pending_delegation.reset();
2212
2213 if (reject_delegation_if_guarded(ctx, pending)) { return; }
2214
2215 set_state(ctx, AgentState::DELEGATING);
2216
2217 // gh#32 (v2.1.6): resume_delegation tool emits a directive with
2218 // resume_from_delegation_id set; the target tier is unknown to the
2219 // tool and must be resolved from storage. Failure to load (unknown
2220 // id, no storage) surfaces a typed error to the lead.
2221 //
2222 // The pre-hook gate is interleaved with resume so both share a
2223 // single early-exit (knots returns gate).
2224 std::vector<Message> resume_history;
2225 bool blocked = false;
2226 if (!pending.resume_from_delegation_id.empty()
2227 && !resolve_resume_delegation(ctx, pending, resume_history)) {
2228 blocked = true;
2229 } else if (fire_delegate_pre_hook(pending, ctx.delegation_depth)) {
2230 logger->info("ON_DELEGATE hook cancelled delegation");
2231 blocked = true;
2232 }
2233 if (blocked) {
2234 set_state(ctx, AgentState::EXECUTING);
2235 return;
2236 }
2237
2238 fire_delegation_start(ctx, pending.target, pending.task);
2239 auto result = run_pending_delegation(
2240 ctx, pending, std::move(resume_history));
2241 push_delegation_result(ctx, pending.target, result);
2242
2243 // gh#64: track consecutive failures against the same target.
2244 if (result.success) {
2245 ctx.last_failed_delegation_target.clear();
2246 ctx.consecutive_failed_delegations = 0;
2247 } else if (pending.target == ctx.last_failed_delegation_target) {
2248 ++ctx.consecutive_failed_delegations;
2249 } else {
2250 ctx.last_failed_delegation_target = pending.target;
2251 ctx.consecutive_failed_delegations = 1;
2252 }
2253
2254 fire_delegation_complete(ctx, pending.target, result);
2255 fire_delegate_complete_hook(pending.target, result.success,
2256 result.summary);
2257
2258 finalize_delegation_result(ctx, result);
2259}
2260
2273void AgentEngine::relay_partial_result(
2274 LoopContext& ctx, const std::string& summary) {
2275 GenerateResult relay_result;
2276 relay_result.content = summary;
2277 relay_result.finish_reason = "stop";
2278 relay_result.tool_calls_json = "[]";
2279 fire_post_generate_hook(
2280 relay_result, ctx.locked_tier, ctx.messages);
2281 ctx.metadata["explicit_completion_summary"] = relay_result.content;
2282 set_state(ctx, AgentState::COMPLETE);
2283}
2284
2293 const std::string& tier, const DelegationResult& result) {
2294 std::string body =
2295 "[COVERAGE GAP from " + tier + "]\n"
2296 "Summary so far: " + result.summary + "\n"
2297 "What's missing: " + result.gap_description;
2298 if (!result.suggested_files.empty()) {
2299 body += "\nSuggested files to inspect:";
2300 for (const auto& f : result.suggested_files) {
2301 body += "\n - " + f;
2302 }
2303 }
2304 return body;
2305}
2306
2323 LoopContext& ctx, const std::string& summary) {
2324 for (auto rit = ctx.messages.rbegin();
2325 rit != ctx.messages.rend(); ++rit) {
2326 if (rit->role == "assistant" && rit->content.empty()) {
2327 rit->content = summary;
2328 break;
2329 }
2330 }
2331}
2332
2353void AgentEngine::finalize_delegation_result(
2354 LoopContext& ctx, const DelegationResult& result) {
2355 // delegation.cpp sets success=false when terminal_reason is non-empty,
2356 // so the two relay branches are disjoint — check them independently.
2357 const bool in_relay_tier =
2358 relay_single_delegate_tiers_.count(ctx.locked_tier) > 0;
2359 if (in_relay_tier && result.coverage_gap) {
2360 Message gap;
2361 gap.role = "user";
2362 gap.content = build_coverage_gap_message(
2363 result.target_tier, result);
2364 ctx.messages.push_back(std::move(gap));
2365 ctx.metadata["relay_status"] = "coverage_gap_suppressed";
2366 logger->info(
2367 "[COVERAGE GAP] suppressing auto-relay for tier={} "
2368 "({} suggested files)",
2369 result.target_tier, result.suggested_files.size());
2370 set_state(ctx, AgentState::EXECUTING);
2371 return;
2372 }
2373 if (result.success && in_relay_tier) {
2374 relay_partial_result(ctx, result.summary);
2375 log_relay_status(ctx);
2376 return;
2377 }
2378 if (!result.terminal_reason.empty() && in_relay_tier) {
2379 relay_partial_result(ctx,
2380 "[partial — budget_exhausted] " + result.summary);
2381 log_relay_status(ctx, result.terminal_reason);
2382 return;
2383 }
2384 bool needs_explicit = tier_requires_explicit_completion(
2385 ctx.locked_tier);
2386 if (result.success && !needs_explicit) {
2387 fold_delegation_summary(ctx, result.summary); // gh#119
2388 }
2389 set_state(ctx, (result.success && !needs_explicit)
2390 ? AgentState::COMPLETE : AgentState::EXECUTING);
2391}
2392
2407void AgentEngine::log_relay_status(LoopContext& ctx,
2408 const std::string& terminal_reason) {
2409 std::string verdict;
2410 if (validation_provider_ != nullptr) {
2411 char* v = validation_provider_(validation_provider_data_);
2412 if (v != nullptr) {
2413 std::string jv(v);
2414 free(v);
2415 auto pos = jv.find("\"verdict\":\"");
2416 if (pos != std::string::npos) {
2417 pos += 11;
2418 auto end = jv.find('"', pos);
2419 if (end != std::string::npos) {
2420 verdict = jv.substr(pos, end - pos);
2421 }
2422 }
2423 }
2424 }
2425 if (!terminal_reason.empty()) {
2426 ctx.metadata["relay_status"] = "budget_exhausted_relayed";
2427 logger->warn(
2428 "Relay: single-delegate result used "
2429 "(partial — terminal_reason={}, verdict={})",
2430 terminal_reason, verdict.empty() ? "none" : verdict);
2431 return;
2432 }
2433 if (verdict == "skipped" || verdict.empty()) {
2434 ctx.metadata["relay_status"] = "validation_skipped";
2435 logger->info(
2436 "Relay: single-delegate result used "
2437 "(lead validation skipped per config)");
2438 } else {
2439 ctx.metadata["relay_status"] = "validated";
2440 logger->info(
2441 "Relay: single-delegate result used "
2442 "(passed lead validation)");
2443 }
2444}
2445
2453void AgentEngine::execute_pending_pipeline(LoopContext& ctx) {
2454 auto pending = std::move(*ctx.pending_pipeline);
2455 ctx.pending_pipeline.reset();
2456
2457 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2458 logger->warn("Pipeline rejected: depth {} >= max {}",
2459 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2460 Message reject;
2461 reject.role = "user";
2462 reject.content = "[PIPELINE REJECTED] Maximum delegation "
2463 "depth reached.";
2464 ctx.messages.push_back(std::move(reject));
2465 return;
2466 }
2467
2468 set_state(ctx, AgentState::DELEGATING);
2469
2470 // gh#33 (v2.1.6): engine-scoped sandbox; non-owning pointer.
2471 auto repo_dir = get_repo_dir();
2472 DelegationManager mgr(run_child_loop_trampoline, this,
2473 tier_res_, repo_dir,
2474 ensure_sandbox_manager());
2475 if (storage_.create_delegation != nullptr) {
2476 mgr.set_storage(&storage_);
2477 }
2478 // gh#29 (v2.1.5): snapshot under the mutex (see execute_pending_
2479 // delegation comment); avoids a torn read against a concurrent
2480 // set_delegation_callbacks call.
2481 auto cb_snap = delegation_callbacks_snapshot();
2482 mgr.set_delegation_callbacks(
2483 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2484 std::vector<DelegationResult> stage_log;
2485 auto result = mgr.execute_pipeline(
2486 ctx, pending.stages, pending.task, stage_log);
2487
2488 std::string tag = result.success ? "COMPLETE" : "FAILED";
2489 std::string content = "[PIPELINE " + tag + "]";
2490 for (size_t i = 0; i < stage_log.size(); ++i) {
2491 content += "\nStage " + std::to_string(i + 1)
2492 + " (" + stage_log[i].target_tier + "): "
2493 + stage_log[i].summary;
2494 }
2495 content += "\nFinal: " + result.summary;
2496 Message result_msg;
2497 result_msg.role = "user";
2498 result_msg.content = std::move(content);
2499 ctx.messages.push_back(std::move(result_msg));
2500
2501 set_state(ctx, AgentState::EXECUTING);
2502}
2503
2512void AgentEngine::fire_delegation_start(
2513 const LoopContext& /*ctx*/,
2514 const std::string& tier,
2515 const std::string& task) {
2516 if (callbacks_.on_delegation_start != nullptr) {
2517 callbacks_.on_delegation_start(
2518 "", tier.c_str(), task.c_str(),
2519 callbacks_.user_data);
2520 }
2521}
2522
2531void AgentEngine::fire_delegation_complete(
2532 const LoopContext& /*ctx*/,
2533 const std::string& tier,
2534 const DelegationResult& result) {
2535 if (callbacks_.on_delegation_complete != nullptr) {
2536 callbacks_.on_delegation_complete(
2537 "", tier.c_str(), result.summary.c_str(),
2538 result.success ? 1 : 0,
2539 callbacks_.user_data);
2540 }
2541}
2542
2543// ── Auto-chain (v1.8.6) ─────────────────────────────────
2544
2554bool AgentEngine::should_auto_chain(
2555 const LoopContext& ctx,
2556 const std::string& finish_reason,
2557 const std::string& content) {
2558 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
2559 return false;
2560 }
2561
2562 std::string auto_chain = tier_res_.get_tier_param(
2563 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2564 if (auto_chain.empty()) {
2565 return false;
2566 }
2567
2568 bool triggered = (finish_reason == "length") ||
2569 (finish_reason == "stop" &&
2570 response_generator_.is_response_complete(content, "[]"));
2571 return triggered;
2572}
2573
2583bool AgentEngine::try_auto_chain(
2584 LoopContext& ctx,
2585 const std::string& finish_reason,
2586 const std::string& content) {
2587 if (!should_auto_chain(ctx, finish_reason, content)) {
2588 return false;
2589 }
2590
2591 if (ctx.delegation_depth > 0) {
2592 logger->info("[AUTO-CHAIN] child depth={}, completing",
2593 ctx.delegation_depth);
2594 set_state(ctx, AgentState::COMPLETE);
2595 return true;
2596 }
2597
2598 // Root: tier change to auto_chain target
2599 std::string target = tier_res_.get_tier_param(
2600 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2601
2602 if (!target.empty()) {
2603 logger->info("[AUTO-CHAIN] root, tier change to '{}'", target);
2604 TierChangeDirective tc(target, "auto_chain");
2605 DirectiveResult r;
2606 dir_tier_change(ctx, tc, r);
2607 }
2608 return !target.empty();
2609}
2610
2611// ── Project dir (v2.1.5, gh#29) ─────────────────────────
2612
2633std::filesystem::path AgentEngine::get_repo_dir() {
2634 if (repo_dir_checked_) {
2635 return cached_repo_dir_.value_or(std::filesystem::path{});
2636 }
2637 repo_dir_checked_ = true;
2638 std::filesystem::path resolved;
2639 if (!project_dir_override_.empty()) {
2640 resolved = project_dir_override_;
2641 logger->info("Project dir for sandbox snapshots: {} (configured)",
2642 resolved.string());
2643 } else {
2644 resolved = std::filesystem::current_path();
2645 logger->info("Project dir for sandbox snapshots: {} (cwd fallback)",
2646 resolved.string());
2647 }
2648 cached_repo_dir_ = resolved;
2649 return resolved;
2650}
2651
2663void AgentEngine::set_project_dir(const std::filesystem::path& project_dir) {
2664 project_dir_override_ = project_dir;
2665 cached_repo_dir_.reset();
2666 repo_dir_checked_ = false;
2667}
2668
2685 LoopContext& ctx,
2686 const std::string& reason,
2687 const std::string& delegation_id) {
2688 Message m;
2689 m.role = "user";
2690 m.content = "[DELEGATION FAILED: resume_delegation] "
2691 + reason + " (delegation_id=" + delegation_id + ")";
2692 ctx.messages.push_back(std::move(m));
2693}
2694
2708bool AgentEngine::fetch_resume_payload(
2709 LoopContext& ctx,
2710 const std::string& id,
2711 nlohmann::json& parsed) {
2712 std::optional<std::string> error;
2713 std::string raw;
2714 if (storage_.load_delegation_with_messages == nullptr) {
2715 error = "storage unavailable";
2716 } else if (!storage_.load_delegation_with_messages(
2717 id.c_str(), raw, storage_.user_data)) {
2718 error = "unknown delegation_id";
2719 } else {
2720 parsed = nlohmann::json::parse(raw, nullptr, false);
2721 if (parsed.is_discarded() || !parsed.is_object()) {
2722 error = "malformed storage payload";
2723 }
2724 }
2725 if (error.has_value()) {
2726 logger->error("resume_delegation '{}': {}", id, *error);
2727 push_resume_failure(ctx, *error, id);
2728 return false;
2729 }
2730 return true;
2731}
2732
2741bool AgentEngine::resolve_resume_delegation(
2742 LoopContext& ctx,
2743 PendingDelegation& pending,
2744 std::vector<Message>& out_history) {
2745 const auto& id = pending.resume_from_delegation_id;
2746 nlohmann::json j;
2747 if (!fetch_resume_payload(ctx, id, j)) {
2748 return false;
2749 }
2750 auto target = j.value("target_tier", std::string{});
2751 if (target.empty()) {
2752 push_resume_failure(ctx, "target_tier missing in storage", id);
2753 return false;
2754 }
2755 pending.target = target;
2756 if (j.contains("messages") && j["messages"].is_array()) {
2757 for (const auto& mj : j["messages"]) {
2758 Message m;
2759 m.role = mj.value("role", "");
2760 m.content = mj.value("content", "");
2761 if (!m.role.empty()) {
2762 out_history.push_back(std::move(m));
2763 }
2764 }
2765 }
2766 logger->info("resume_delegation '{}': loaded {} messages, target='{}'",
2767 id, out_history.size(), target);
2768 return true;
2769}
2770
2785DelegationResult AgentEngine::run_pending_delegation(
2786 LoopContext& ctx,
2787 const PendingDelegation& pending,
2788 std::vector<Message> resume_history) {
2789 std::optional<int> max_turns;
2790 if (pending.max_turns > 0) { max_turns = pending.max_turns; }
2791 DelegationManager mgr(run_child_loop_trampoline, this,
2792 tier_res_, get_repo_dir(),
2793 ensure_sandbox_manager());
2794 if (storage_.create_delegation != nullptr) {
2795 mgr.set_storage(&storage_);
2796 }
2797 auto cb_snap = delegation_callbacks_snapshot();
2798 mgr.set_delegation_callbacks(
2799 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2800 if (resume_history.empty()) {
2801 return mgr.execute_delegation(
2802 ctx, pending.target, pending.task, max_turns);
2803 }
2804 return mgr.execute_resume_delegation(
2805 ctx, pending.target, pending.task,
2806 std::move(resume_history), max_turns);
2807}
2808
2816SandboxManager* AgentEngine::ensure_sandbox_manager() {
2817 if (sandbox_mgr_) {
2818 return &*sandbox_mgr_;
2819 }
2820 auto repo_dir = get_repo_dir();
2821 if (repo_dir.empty()) {
2822 return nullptr;
2823 }
2824 sandbox_mgr_.emplace(repo_dir);
2825 return &*sandbox_mgr_;
2826}
2827
2828// ── Conversation state (v2.0.2) ─────────────────────────────
2829
2836void AgentEngine::set_system_prompt(const std::string& prompt) {
2837 system_prompt_ = prompt;
2838}
2839
2846void AgentEngine::set_session_logger(SessionLogger* log) {
2847 session_logger_ = log;
2848}
2849
2863std::vector<Message> AgentEngine::run_turn(const std::string& input) {
2864 // gh#40 (v2.1.10): the drain loop turns mid-generation queued user
2865 // messages into subsequent turns at this single top-level COMPLETE
2866 // boundary. running_flag_ lets the facade reject
2867 // entropic_queue_user_message with INVALID_STATE when idle.
2868 running_flag_.store(true);
2869 if (conversation_.empty() && !system_prompt_.empty()) {
2870 Message sys;
2871 sys.role = "system";
2872 sys.content = system_prompt_;
2873 conversation_.push_back(std::move(sys));
2874 }
2875 auto result = run_drain_loop(input, /*tier_override=*/"");
2876 running_flag_.store(false);
2877 return result;
2878}
2879
2897std::vector<Message> AgentEngine::run_turn_as(const std::string& tier,
2898 const std::string& input) {
2899 running_flag_.store(true);
2900 seed_system_prompt_for_tier(tier);
2901 auto result = run_drain_loop(input, tier);
2902 running_flag_.store(false);
2903 return result;
2904}
2905
2918void AgentEngine::seed_system_prompt_for_tier(const std::string& tier) {
2919 if (!conversation_.empty()) { return; }
2920 auto it = tier_info_.find(tier);
2921 const std::string& sp =
2922 (it != tier_info_.end() && !it->second.system_prompt.empty())
2923 ? it->second.system_prompt
2924 : system_prompt_;
2925 if (sp.empty()) { return; }
2926 Message sys;
2927 sys.role = "system";
2928 sys.content = sp;
2929 conversation_.push_back(std::move(sys));
2930}
2931
2940std::vector<Message> AgentEngine::run_drain_loop(
2941 std::string pending, const std::string& tier_override) {
2942 std::vector<Message> result;
2943 while (true) {
2944 Message usr;
2945 usr.role = "user";
2946 usr.content = pending;
2947 conversation_.push_back(std::move(usr));
2948
2949 size_t sent_len = conversation_.size();
2950 result = run(conversation_, tier_override); // copy — run() may mutate
2951 for (size_t i = sent_len; i < result.size(); ++i) {
2952 conversation_.push_back(result[i]);
2953 }
2954 auto next = pop_queued_user_message();
2955 if (!next.has_value()) { break; }
2956 fire_queue_consumed(*next, user_message_queue_depth());
2957 pending = std::move(*next);
2958 }
2959 return result;
2960}
2961
2984void AgentEngine::seed_system_prompt(
2985 const std::vector<Message>& new_messages) {
2986 bool caller_has_system = false;
2987 for (const auto& m : new_messages) {
2988 if (m.role == "system") { caller_has_system = true; break; }
2989 }
2990 if (conversation_.empty() && !system_prompt_.empty()
2991 && !caller_has_system) {
2992 Message sys;
2993 sys.role = "system";
2994 sys.content = system_prompt_;
2995 conversation_.push_back(std::move(sys));
2996 }
2997}
2998
3006bool AgentEngine::prepare_next_turn(std::vector<Message>& pending) {
3007 auto next = pop_queued_user_message();
3008 if (!next.has_value()) { return false; }
3009 fire_queue_consumed(*next, user_message_queue_depth());
3010 Message usr;
3011 usr.role = "user";
3012 usr.content = std::move(*next);
3013 pending.clear();
3014 pending.push_back(std::move(usr));
3015 return true;
3016}
3017
3025std::vector<Message> AgentEngine::run_turn(std::vector<Message> new_messages) {
3026 // gh#40 (v2.1.10): mirror the single-string overload's drain
3027 // loop. Queued messages enqueued via entropic_queue_user_message
3028 // become subsequent plain-text user turns at this top-level
3029 // boundary (no content_parts — the queue ABI is text-only).
3030 running_flag_.store(true);
3031 seed_system_prompt(new_messages);
3032 std::vector<Message> pending = std::move(new_messages);
3033 std::vector<Message> result;
3034 while (true) {
3035 for (auto& m : pending) {
3036 conversation_.push_back(std::move(m));
3037 }
3038 size_t sent_len = conversation_.size();
3039 result = run(conversation_);
3040 for (size_t i = sent_len; i < result.size(); ++i) {
3041 conversation_.push_back(result[i]);
3042 }
3043 if (!prepare_next_turn(pending)) { break; }
3044 }
3045 running_flag_.store(false);
3046 return result;
3047}
3048
3060int AgentEngine::run_streaming(
3061 const std::string& input,
3062 TokenCallback on_token,
3063 void* user_data,
3064 int* cancel_flag)
3065{
3066 if (session_logger_) {
3067 session_logger_->log_user_input(input);
3068 }
3069
3070 StreamThinkFilter filter(on_token, user_data);
3071 if (session_logger_ && session_logger_->is_open()) {
3072 filter.set_raw_callback(
3073 SessionLogger::raw_token_callback,
3074 session_logger_);
3075 }
3076
3077 struct Ctx {
3078 StreamThinkFilter* filter;
3079 int* cancel;
3080 AgentEngine* engine;
3081 };
3082 Ctx sctx{&filter, cancel_flag, this};
3083
3084 EngineCallbacks cbs{};
3085 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
3086 auto* c = static_cast<Ctx*>(ud);
3087 if (c->cancel && *c->cancel) {
3088 c->engine->interrupt();
3089 return;
3090 }
3091 c->filter->on_token(t, l);
3092 };
3093 cbs.user_data = &sctx;
3094 set_callbacks(cbs);
3095
3096 auto result = run_turn(input);
3097 filter.flush();
3098
3099 if (session_logger_) {
3100 session_logger_->end_turn();
3101 }
3102 if (cancel_flag && *cancel_flag) { return 1; }
3103 return 0;
3104}
3105
3113static std::string concat_user_echo(
3114 const std::vector<Message>& messages) {
3115 std::string echo;
3116 for (const auto& m : messages) {
3117 if (m.role != "user" || m.content.empty()) { continue; }
3118 if (!echo.empty()) { echo += '\n'; }
3119 echo += m.content;
3120 }
3121 return echo;
3122}
3123
3140int AgentEngine::run_streaming(
3141 std::vector<Message> new_messages,
3142 TokenCallback on_token,
3143 void* user_data,
3144 int* cancel_flag)
3145{
3146 if (session_logger_) {
3147 session_logger_->log_user_input(concat_user_echo(new_messages));
3148 }
3149
3150 StreamThinkFilter filter(on_token, user_data);
3151 if (session_logger_ && session_logger_->is_open()) {
3152 filter.set_raw_callback(
3153 SessionLogger::raw_token_callback,
3154 session_logger_);
3155 }
3156
3157 struct Ctx {
3158 StreamThinkFilter* filter;
3159 int* cancel;
3160 AgentEngine* engine;
3161 };
3162 Ctx sctx{&filter, cancel_flag, this};
3163
3164 EngineCallbacks cbs{};
3165 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
3166 auto* c = static_cast<Ctx*>(ud);
3167 if (c->cancel && *c->cancel) {
3168 c->engine->interrupt();
3169 return;
3170 }
3171 c->filter->on_token(t, l);
3172 };
3173 cbs.user_data = &sctx;
3174 set_callbacks(cbs);
3175
3176 auto result = run_turn(std::move(new_messages));
3177 filter.flush();
3178
3179 if (session_logger_) {
3180 session_logger_->end_turn();
3181 }
3182 if (cancel_flag && *cancel_flag) { return 1; }
3183 return 0;
3184}
3185
3191void AgentEngine::clear_conversation() {
3192 conversation_.clear();
3193 logger->info("conversation cleared");
3194}
3195
3202size_t AgentEngine::message_count() const {
3203 return conversation_.size();
3204}
3205
3212const std::vector<Message>& AgentEngine::get_messages() const {
3213 return conversation_;
3214}
3215
3216// ── Mid-generation user-message queue (gh#40, v2.1.10) ─────────
3217
3223bool AgentEngine::queue_user_message(const std::string& message) {
3224 std::lock_guard lock(queue_mutex_);
3225 int cap = loop_config_.message_queue_capacity;
3226 if (cap < 0) { cap = 0; }
3227 if (user_message_queue_.size()
3228 >= static_cast<size_t>(cap)) {
3229 return false;
3230 }
3231 user_message_queue_.push_back(message);
3232 logger->info("queued mid-gen user message: depth={}",
3233 user_message_queue_.size());
3234 return true;
3235}
3236
3242size_t AgentEngine::user_message_queue_depth() const {
3243 std::lock_guard lock(queue_mutex_);
3244 return user_message_queue_.size();
3245}
3246
3252void AgentEngine::clear_user_message_queue() {
3253 std::lock_guard lock(queue_mutex_);
3254 size_t dropped = user_message_queue_.size();
3255 user_message_queue_.clear();
3256 if (dropped > 0) {
3257 logger->info("cleared mid-gen queue: dropped={}", dropped);
3258 }
3259}
3260
3266void AgentEngine::set_message_queue_capacity(int cap) {
3267 std::lock_guard lock(queue_mutex_);
3268 loop_config_.message_queue_capacity = cap < 0 ? 0 : cap;
3269}
3270
3276std::optional<std::string>
3277AgentEngine::pop_queued_user_message() {
3278 std::lock_guard lock(queue_mutex_);
3279 if (user_message_queue_.empty()) {
3280 return std::nullopt;
3281 }
3282 std::string front = std::move(user_message_queue_.front());
3283 user_message_queue_.pop_front();
3284 return front;
3285}
3286
3292void AgentEngine::fire_queue_consumed(const std::string& consumed,
3293 size_t remaining) {
3294 if (queue_observer_ != nullptr) {
3295 queue_observer_(
3296 consumed.c_str(), remaining, queue_observer_data_);
3297 }
3298}
3299
3305void AgentEngine::set_queue_observer(
3306 void (*observer)(const char*, size_t, void*),
3307 void* user_data) {
3308 queue_observer_ = observer;
3309 queue_observer_data_ = user_data;
3310}
3311
3323void AgentEngine::set_state_observer(
3324 void (*observer)(int, void*),
3325 void* user_data) {
3326 state_observer_ = observer;
3327 state_observer_data_ = user_data;
3328 response_generator_.set_state_observer(observer, user_data);
3329}
3330
3331// ── Directive hooks (v2.0.2) ────────────────────────────────
3332
3339ToolExecutorHooks AgentEngine::build_directive_hooks() {
3340 ToolExecutorHooks hooks;
3341 hooks.process_directives = [](
3342 LoopContext& ctx,
3343 const std::vector<const Directive*>& dirs,
3344 void* ud) -> DirectiveResult {
3345 return static_cast<AgentEngine*>(ud)
3346 ->directive_processor().process(ctx, dirs);
3347 };
3348 hooks.user_data = this;
3349 return hooks;
3350}
3351
3352// ── Tier resolution (v2.0.2) ────────────────────────────────
3353
3361void AgentEngine::set_tier_info(
3362 const std::string& name,
3363 const ChildContextInfo& info)
3364{
3365 tier_info_[name] = info;
3366}
3367
3375bool AgentEngine::has_tier(const std::string& name) const {
3376 return tier_info_.count(name) > 0;
3377}
3378
3387std::vector<std::string> AgentEngine::get_tier_allowed_tools(
3388 const std::string& name) const {
3389 auto it = tier_info_.find(name);
3390 if (it == tier_info_.end()) { return {}; }
3391 return it->second.allowed_tools;
3392}
3393
3401const std::string& AgentEngine::tier_system_prompt(
3402 const std::string& name) const {
3403 static const std::string kEmpty;
3404 auto it = tier_info_.find(name);
3405 return it == tier_info_.end() ? kEmpty : it->second.system_prompt;
3406}
3407
3414void AgentEngine::set_relay_single_delegate(const std::string& name) {
3415 relay_single_delegate_tiers_.insert(name);
3416}
3417
3425void AgentEngine::set_handoff_rules(
3426 const std::unordered_map<std::string,
3427 std::vector<std::string>>& rules)
3428{
3429 handoff_rules_ = rules;
3430 wire_internal_tier_resolution();
3431}
3432
3440ChildContextInfo AgentEngine::tri_resolve_tier(
3441 const std::string& name, void* ud) {
3442 auto* self = static_cast<AgentEngine*>(ud);
3443 auto it = self->tier_info_.find(name);
3444 ChildContextInfo info;
3445 if (it == self->tier_info_.end()) {
3446 info.valid = false;
3447 } else {
3448 info = it->second;
3449 }
3450 return info;
3451}
3452
3461bool AgentEngine::tri_tier_exists(const std::string& name, void* ud) {
3462 auto* self = static_cast<AgentEngine*>(ud);
3463 return self->tier_info_.count(name) > 0;
3464}
3465
3475std::vector<std::string> AgentEngine::tri_get_handoff_targets(
3476 const std::string& name, void* ud) {
3477 auto* self = static_cast<AgentEngine*>(ud);
3478 auto it = self->handoff_rules_.find(name);
3479 std::vector<std::string> result;
3480 if (it != self->handoff_rules_.end()) { result = it->second; }
3481 return result;
3482}
3483
3491static std::string join_csv(const std::vector<std::string>& v) {
3492 std::string out;
3493 for (const auto& s : v) {
3494 if (!out.empty()) { out += ','; }
3495 out += s;
3496 }
3497 return out;
3498}
3499
3524std::string AgentEngine::tri_get_tier_param(const std::string& name,
3525 const std::string& param, void* ud) {
3526 auto* self = static_cast<AgentEngine*>(ud);
3527 auto it = self->tier_info_.find(name);
3528 if (it == self->tier_info_.end()) { return ""; }
3529 const auto& info = it->second;
3530 std::string result;
3531 if (param == "explicit_completion") {
3532 result = info.explicit_completion ? "true" : "false";
3533 }
3534 if (param == "max_iterations" && info.max_iterations_override >= 0) {
3535 result = std::to_string(info.max_iterations_override);
3536 }
3537 if (param == "max_tool_calls_per_turn"
3538 && info.max_tool_calls_per_turn_override >= 0) {
3539 result = std::to_string(info.max_tool_calls_per_turn_override);
3540 }
3541 if (param == "max_consecutive_empty_turns"
3542 && info.max_consecutive_empty_turns_override >= 0) {
3543 result = std::to_string(info.max_consecutive_empty_turns_override);
3544 }
3545 if (param == "allowed_tools" && !info.allowed_tools.empty()) {
3546 result = join_csv(info.allowed_tools);
3547 }
3548 return result;
3549}
3550
3556void AgentEngine::wire_internal_tier_resolution() {
3557 TierResolutionInterface tri;
3558 tri.resolve_tier = &AgentEngine::tri_resolve_tier;
3559 tri.tier_exists = &AgentEngine::tri_tier_exists;
3560 tri.get_handoff_targets = &AgentEngine::tri_get_handoff_targets;
3561 tri.get_tier_param = &AgentEngine::tri_get_tier_param;
3562 tri.user_data = this;
3563 set_tier_resolution(tri);
3564}
3565
3566} // namespace entropic
Core agent execution engine.
Definition engine.h:62
void run_loop(LoopContext &ctx, bool inherit_interrupt=false)
Run the engine loop on a pre-built context.
Definition engine.cpp:348
AgentEngine(const InferenceInterface &inference, const LoopConfig &loop_config, const CompactionConfig &compaction_config)
Construct an agent engine.
Definition engine.cpp:135
Manages session_model.log for raw streaming content.
Streaming filter that removes <think> blocks from output.
void set_raw_callback(TokenCallback cb, void *ud)
Set optional raw callback (receives ALL tokens unfiltered).
void flush()
Flush any buffered partial tag content.
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.
Core agent execution engine.
ent_decision_t
Consumer decision returned from delegation callbacks.
Definition entropic.h:1091
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
spdlog initialization and logger access.
ENTROPIC_EXPORT std::shared_ptr< spdlog::logger > get(const std::string &name)
Get or create a named logger.
Definition logging.cpp:211
Activate model on GPU (WARM → ACTIVE).
static std::string 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:2292
static void fire_context_assemble_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_CONTEXT_ASSEMBLE info hook.
Definition engine.cpp:101
static std::string extract_system_prompt(const std::vector< Message > &messages)
Extract the first system message content from messages.
Definition engine.cpp:1747
static double now_seconds()
Get current time as seconds since epoch.
Definition engine.cpp:119
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:1344
ToolResultKind
Categorical outcome of a single tool invocation.
Definition tool_result.h:32
@ 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:1887
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:1367
static std::string json_escape_engine(const std::string &s)
JSON-escape a string (no surrounding quotes).
Definition engine.cpp:1580
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:1687
@ 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:1513
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:2322
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:2144
static void fire_loop_start_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_START info hook.
Definition engine.cpp:52
static std::string build_tool_manifest(const std::vector< Message > &messages)
Build tool call manifest from conversation messages.
Definition engine.cpp:1603
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:1635
static const std::array< const char *, 256 > & json_escape_table()
256-entry table mapping bytes to JSON escape sequences.
Definition engine.cpp:1560
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:1729
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:2124
static std::string join_csv(const std::vector< std::string > &v)
Join a vector of strings with comma separators.
Definition engine.cpp:3491
static void run_child_loop_trampoline(LoopContext &ctx, void *user_data)
Trampoline for DelegationManager to call engine loop.
Definition engine.cpp:2064
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:37
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:2077
static std::string concat_user_echo(const std::vector< Message > &messages)
Concatenate user-role message text for session-log echo.
Definition engine.cpp:3113
AgentState
C++ enum class for agent execution states.
static void fire_loop_iteration_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_ITERATION info hook.
Definition engine.cpp:84
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:2684
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:2097
static void fire_loop_end_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_END info hook.
Definition engine.cpp:68
Filesystem-based sandbox isolation for delegations.
Request describing a delegation that is about to run.
Definition entropic.h:1105
Result of a finalized delegation, delivered to the consumer.
Definition entropic.h:1128
Grouped consumer-registered delegation callbacks (gh#29).
Definition engine.h:89
Resolved tier information for building child delegation contexts.
Auto-compaction configuration.
Definition config.h:742
Update a keyed persistent context anchor.
Definition directives.h:219
Engine-level hooks called during context management.
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:281
Callback function pointer types for engine events.
void(* on_stream_chunk)(const char *chunk, size_t len, void *ud)
Per-token streaming.
Configuration for the agentic loop.
Mutable state carried through the agentic loop.
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)
int consecutive_errors
Error streak counter.
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)
int delegation_depth
0 = root, 1+ = child
AgentState state
Current state.
std::vector< Message > messages
Conversation history.
std::string locked_tier
Tier locked for this loop ("" = none)
double start_time
Loop start (seconds since epoch)
int tokens_used
Total tokens consumed.
int errors
Total errors encountered.
int duration_ms() const
Get loop duration in milliseconds.
int tool_calls
Total tool calls executed.
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
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.
Typed outcome for POST_TOOL_CALL hook consumers.
UTF-8 validation + replacement at every system boundary where bytes change ownership.