Entropic 2.9.4
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
13
14#include <nlohmann/json.hpp>
15
16#include <algorithm>
17#include <array>
18#include <chrono>
19#include <sstream>
20
21static auto logger = entropic::log::get("core.engine");
22
23namespace entropic {
24
25// ── Hook dispatch helpers ────────────────────────────────
26
35static void fire_hook_info(const HookInterface& hooks,
37 const char* json) {
38 if (hooks.fire_info != nullptr) {
39 hooks.fire_info(hooks.registry, point, json);
40 }
41}
42
50static void fire_loop_start_hook(const HookInterface& hooks,
51 const LoopContext& ctx) {
52 std::string json = "{\"message_count\":"
53 + std::to_string(ctx.messages.size())
54 + ",\"delegation_depth\":"
55 + std::to_string(ctx.delegation_depth) + "}";
56 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_START, json.c_str());
57}
58
66static void fire_loop_end_hook(const HookInterface& hooks,
67 const LoopContext& ctx) {
68 std::string json = "{\"final_state\":\""
69 + std::string(agent_state_name(ctx.state))
70 + "\",\"iterations\":"
71 + std::to_string(ctx.metrics.iterations) + "}";
72 fire_hook_info(hooks, ENTROPIC_HOOK_ON_LOOP_END, json.c_str());
73}
74
82static void fire_loop_iteration_hook(const HookInterface& hooks,
83 const LoopContext& ctx) {
84 std::string json = "{\"iteration\":"
85 + std::to_string(ctx.metrics.iterations)
86 + ",\"state\":\"" + agent_state_name(ctx.state)
87 + "\",\"consecutive_errors\":"
88 + std::to_string(ctx.consecutive_errors) + "}";
90}
91
99static void fire_context_assemble_hook(const HookInterface& hooks,
100 const LoopContext& ctx) {
101 std::string json = "{\"message_count\":"
102 + std::to_string(ctx.messages.size()) + "}";
104 json.c_str());
105}
106
107// Forward declaration
108static void remove_anchor_messages(LoopContext& ctx,
109 const std::string& key);
110
117static double now_seconds() {
118 auto now = std::chrono::steady_clock::now();
119 auto dur = now.time_since_epoch();
120 auto ms = std::chrono::duration_cast<
121 std::chrono::milliseconds>(dur);
122 return static_cast<double>(ms.count()) / 1000.0;
123}
124
134 const InferenceInterface& inference,
135 const LoopConfig& loop_config,
136 const CompactionConfig& compaction_config)
137 : inference_(inference),
138 loop_config_(loop_config),
139 token_counter_(loop_config.context_length),
140 compaction_manager_(compaction_config, token_counter_),
141 context_manager_(
142 compaction_manager_, callbacks_,
144 [this](LoopContext& ctx) { reinject_context_anchors(ctx); }
145 }),
146 response_generator_(
147 inference, loop_config, callbacks_,
148 GenerationEvents{&interrupt_flag_, &pause_flag_}) {
149 register_directive_handlers();
150}
151
158void AgentEngine::set_callbacks(const EngineCallbacks& callbacks) {
159 callbacks_ = callbacks;
160}
161
168void AgentEngine::set_tool_executor(
169 const ToolExecutionInterface& tool_exec) {
170 tool_exec_ = tool_exec;
171}
172
179void AgentEngine::set_tier_resolution(
180 const TierResolutionInterface& tier_res) {
181 tier_res_ = tier_res;
182}
183
190void AgentEngine::set_storage(const StorageInterface& storage) {
191 storage_ = storage;
192 compaction_manager_.set_storage(&storage_);
193}
194
201void AgentEngine::set_hooks(const HookInterface& hooks) {
202 hooks_ = hooks;
203 response_generator_.set_hooks(hooks);
204 context_manager_.set_hooks(hooks);
205 directive_processor_.set_hooks(hooks);
206}
207
219void AgentEngine::set_stream_observer(
220 TokenCallback observer, void* user_data) {
221 response_generator_.set_stream_observer(observer, user_data);
222}
223
232void AgentEngine::set_delegation_callbacks(
233 ent_decision_t (*on_start)(const ent_delegation_request_t*, void*),
234 ent_decision_t (*on_complete)(const ent_delegation_result_t*, void*),
235 void* user_data) {
236 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
237 delegation_cb_.start = on_start;
238 delegation_cb_.complete = on_complete;
239 delegation_cb_.user_data = user_data;
240}
241
249AgentEngine::delegation_callbacks_snapshot() const {
250 std::lock_guard<std::mutex> lock(delegation_cb_mutex_);
251 return delegation_cb_;
252}
253
261void AgentEngine::set_validation_provider(
262 char* (*provider)(void*), void* user_data) {
263 validation_provider_ = provider;
264 validation_provider_data_ = user_data;
265}
266
273const TierResolutionInterface& AgentEngine::tier_resolution() const {
274 return tier_res_;
275}
276
290void AgentEngine::apply_identity_overrides(LoopContext& ctx) {
291 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
292 return;
293 }
294 auto mi = tier_res_.get_tier_param(
295 ctx.locked_tier, "max_iterations", tier_res_.user_data);
296 if (!mi.empty()) {
297 ctx.effective_max_iterations = std::atoi(mi.c_str());
298 logger->info("[override] tier={} max_iterations={}",
300 }
301 auto mt = tier_res_.get_tier_param(
302 ctx.locked_tier, "max_tool_calls_per_turn", tier_res_.user_data);
303 if (!mt.empty()) {
304 ctx.effective_max_tool_calls_per_turn = std::atoi(mt.c_str());
305 logger->info("[override] tier={} max_tool_calls_per_turn={}",
307 }
308}
309
317int AgentEngine::resolve_max_iterations(const LoopContext& ctx) const {
318 return ctx.effective_max_iterations >= 0
319 ? ctx.effective_max_iterations
320 : loop_config_.max_iterations;
321}
322
330int AgentEngine::resolve_max_tool_calls(const LoopContext& ctx) const {
331 return ctx.effective_max_tool_calls_per_turn >= 0
332 ? ctx.effective_max_tool_calls_per_turn
333 : loop_config_.max_tool_calls_per_turn;
334}
335
343void AgentEngine::run_loop(LoopContext& ctx, bool inherit_interrupt) {
344 // gh#81 (v2.4.3): child delegation loops inherit the parent's
345 // interrupt state — clearing it here would swallow a parent
346 // interrupt raised just before the child was dispatched. Only a
347 // fresh top-level turn resets.
348 if (!inherit_interrupt) {
349 reset_interrupt();
350 pause_flag_.store(false);
351 }
352 apply_identity_overrides(ctx);
353 reinject_context_anchors(ctx);
354 if (ctx.metrics.start_time == 0.0) {
356 }
357 set_state(ctx, AgentState::PLANNING);
358 loop(ctx);
360 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
361 auto& tm = per_tier_metrics_[
362 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
363 tm.iterations += ctx.metrics.iterations;
364 tm.tool_calls += ctx.metrics.tool_calls;
365 tm.tokens_used += ctx.metrics.tokens_used;
366 tm.errors += ctx.metrics.errors;
367 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
368}
369
379std::vector<Message> AgentEngine::run(std::vector<Message> messages,
380 const std::string& tier_override) {
381 // gh#35: update activity timestamp at run() entry so any host
382 // polling `seconds_since_last_activity` for an idle-exit policy
383 // sees a fresh value as soon as work begins.
384 last_activity_epoch_s_.store(
385 std::chrono::duration_cast<std::chrono::seconds>(
386 std::chrono::system_clock::now().time_since_epoch()).count());
387
388 LoopContext ctx;
389 ctx.messages = std::move(messages);
390 ctx.locked_tier = tier_override; // gh#99: "" routes; non-empty locks
392
393 init_session_conversation(ctx);
394
395 reset_interrupt();
396 pause_flag_.store(false);
397
398 reinject_context_anchors(ctx);
399 set_state(ctx, AgentState::PLANNING);
400
401 loop(ctx);
402
404 accumulate_run_metrics(ctx);
405 logger->info("Loop complete: {} iterations, {}ms",
407 return ctx.messages;
408}
409
416void AgentEngine::init_session_conversation(LoopContext& ctx) {
417 // gh#48 (v2.1.12): create the root conversation row when storage
418 // is wired so `ctx.conversation_id` carries a valid FK into
419 // `conversations(id)`. Every downstream delegation copies
420 // `parent_ctx.conversation_id` into `child_ctx.parent_conversation_id`
421 // (`delegation.cpp:387`); without this, the empty string propagates
422 // and every `INSERT INTO delegations` violates the FK silently,
423 // making `entropic.followup` return empty across an entire session.
424 if (storage_.create_conversation == nullptr) { return; }
425 std::string conv_id;
426 if (storage_.create_conversation("session", conv_id, storage_.user_data)
427 && !conv_id.empty()) {
428 ctx.conversation_id = std::move(conv_id);
429 } else {
430 logger->warn("Storage create_conversation failed at run() init; "
431 "delegations will not persist this session (gh#48)");
432 }
433}
434
441void AgentEngine::accumulate_run_metrics(LoopContext& ctx) {
442 last_metrics_ = ctx.metrics; // P2-15: snapshot for entropic_status
443 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
444 auto& tm = per_tier_metrics_[
445 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
446 tm.iterations += ctx.metrics.iterations;
447 tm.tool_calls += ctx.metrics.tool_calls;
448 tm.tokens_used += ctx.metrics.tokens_used;
449 tm.errors += ctx.metrics.errors;
450 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
451}
452
459void AgentEngine::loop(LoopContext& ctx) {
460 fire_loop_start_hook(hooks_, ctx); // ON_LOOP_START (v1.9.1)
461
462 while (!should_stop(ctx)) {
463 ctx.metrics.iterations++;
464
465 if (interrupt_flag_.load()) {
466 set_state(ctx, AgentState::INTERRUPTED);
467 break;
468 }
469
470 execute_iteration(ctx);
471
472 if (ctx.state == AgentState::ERROR) {
473 break;
474 }
475 }
476
477 if (ctx.metrics.iterations >= resolve_max_iterations(ctx)
478 && !is_terminal_state(ctx)) {
479 // P3-18 follow-up (2.0.6-rc16.2): force synthetic completion
480 // so the facade returns a proper result when the cap is hit.
481 // E7 (2.0.6-rc18): mark ctx.metadata with the terminal reason
482 // so delegate result and parent-tier relay can surface that
483 // the child did not complete naturally.
484 logger->warn("Loop ended due to max iterations ({}/{}) — "
485 "forcing synthetic entropic.complete",
486 ctx.metrics.iterations, resolve_max_iterations(ctx));
487 Message forced;
488 forced.role = "assistant";
489 forced.content = "[iteration cap reached after "
490 + std::to_string(ctx.metrics.iterations)
491 + " iterations — returning current state]";
492 ctx.messages.push_back(std::move(forced));
493 ctx.metadata["terminal_reason"] = "budget_exhausted";
494 set_state(ctx, AgentState::COMPLETE);
495 }
496
497 fire_loop_end_hook(hooks_, ctx); // ON_LOOP_END (v1.9.1)
498}
499
506void AgentEngine::execute_iteration(LoopContext& ctx) {
507 logger->info("[LOOP] iter {}/{} state={} msgs={}",
508 ctx.metrics.iterations,
509 resolve_max_iterations(ctx),
510 agent_state_name(ctx.state),
511 ctx.messages.size());
512
513 fire_loop_iteration_hook(hooks_, ctx); // ON_LOOP_ITERATION (v1.9.1)
514
515 context_manager_.refresh_context_limit(ctx, 0);
516 context_manager_.prune_old_tool_results(ctx);
517 context_manager_.check_compaction(ctx);
518
519 fire_context_assemble_hook(hooks_, ctx); // ON_CONTEXT_ASSEMBLE (v1.9.1)
520
521 set_state(ctx, AgentState::EXECUTING);
522
523 // Hook: PRE_GENERATE — can cancel (v1.9.1)
524 if (fire_pre_hook(ENTROPIC_HOOK_PRE_GENERATE, ctx.metrics.iterations)) {
525 set_state(ctx, AgentState::COMPLETE);
526 return;
527 }
528
529 auto result = response_generator_.generate_response(ctx);
530 dispatch_post_generate(ctx, result);
531 process_generation_result(ctx, result);
532}
533
541void AgentEngine::process_generation_result(LoopContext& ctx,
542 GenerateResult& result) {
543 auto [cleaned, tool_calls] = parse_tool_calls(result.content);
544 logger->info("[ITER] finish={}, {} tool call(s), {} chars",
545 result.finish_reason, tool_calls.size(),
546 cleaned.size());
547 Message assistant_msg{"assistant", cleaned};
548 ctx.messages.push_back(std::move(assistant_msg));
549
550 bool made_tool_call =
551 !tool_calls.empty() && tool_exec_.process_tool_calls != nullptr;
552 // gh#84 (v2.5.1): the budget resets on genuine progress, not on a
553 // merely-parsed call — process_tool_results reports whether any
554 // tool actually executed (ok / ok_empty).
555 bool made_progress = false;
556 if (made_tool_call) {
557 made_progress = process_tool_results(ctx, tool_calls);
558 } else {
559 evaluate_no_tool_decision(ctx, cleaned, result.finish_reason);
560 }
561
562 // gh#80 (v2.5.0): charge the thinking budget before dispatch so a
563 // budget-triggered terminal is honored by dispatch_pending_or_halt.
564 charge_thinking_budget(ctx, result.content.size(), made_progress);
565
566 dispatch_pending_or_halt(ctx);
567}
568
574void AgentEngine::charge_thinking_budget(
575 LoopContext& ctx, size_t content_len, bool made_tool_call) {
576 if (loop_config_.budget_mode == BudgetMode::off) { return; }
577
578 // A tool call is productive action — reset the window and the
579 // nudge latch so the model earns a fresh thinking allowance.
580 if (made_tool_call) {
581 ctx.budget_tokens_since_tool = 0;
582 ctx.budget_window_start_s = 0.0;
583 ctx.budget_completion_nudged = false;
584 return;
585 }
586
587 int units = budget_units_consumed(ctx, content_len);
588 if (units < loop_config_.budget_limit) { return; }
589
590 if (!ctx.budget_completion_nudged) {
591 nudge_budget_completion(ctx);
592 } else {
593 hard_cut_budget(ctx);
594 }
595}
596
602int AgentEngine::budget_units_consumed(LoopContext& ctx, size_t content_len) {
603 if (loop_config_.budget_mode == BudgetMode::tokens) {
604 // Estimate generated tokens from content length — the
605 // function-pointer inference ABI doesn't thread an exact count
606 // back. ~4 chars/token; content includes <think> blocks, which
607 // is exactly the deliberation we want to charge.
608 constexpr size_t kCharsPerToken = 4;
609 ctx.budget_tokens_since_tool +=
610 static_cast<int>(content_len / kCharsPerToken);
611 return ctx.budget_tokens_since_tool;
612 }
613 // wall_clock
614 if (ctx.budget_window_start_s == 0.0) {
615 ctx.budget_window_start_s = now_seconds();
616 }
617 return static_cast<int>(now_seconds() - ctx.budget_window_start_s);
618}
619
625void AgentEngine::nudge_budget_completion(LoopContext& ctx) {
626 ctx.budget_completion_nudged = true;
627 ctx.budget_tokens_since_tool = 0;
628 ctx.budget_window_start_s =
629 (loop_config_.budget_mode == BudgetMode::wall_clock)
630 ? now_seconds() : 0.0;
631 logger->warn("[BUDGET] thinking budget reached ({} {}); nudging completion",
632 loop_config_.budget_limit,
633 loop_config_.budget_mode == BudgetMode::tokens
634 ? "tokens" : "seconds");
635 Message nudge{"user",
636 "[engine] thinking budget reached — emit entropic.complete now "
637 "with your best current answer. Further deliberation without a "
638 "tool call will be cut off."};
639 ctx.messages.push_back(std::move(nudge));
640}
641
647void AgentEngine::hard_cut_budget(LoopContext& ctx) {
648 logger->warn("[BUDGET] thinking budget exhausted after nudge — "
649 "hard-cutting turn");
650 Message cut{"assistant",
651 "[thinking budget exhausted — the turn was hard-cut after the "
652 "completion nudge went unheeded; no tool call was emitted]"};
653 ctx.messages.push_back(std::move(cut));
654 ctx.metadata["terminal_reason"] = "budget_exhausted_thinking";
655 set_state(ctx, AgentState::COMPLETE);
656}
657
675void AgentEngine::dispatch_pending_or_halt(LoopContext& ctx) {
676 if (interrupt_flag_.load() && !is_terminal_state(ctx)) {
677 logger->info("[ITER] interrupt observed during tool processing — "
678 "halting before pending dispatch");
679 set_state(ctx, AgentState::INTERRUPTED);
680 return;
681 }
682
683 if (is_terminal_state(ctx)) {
684 if (ctx.pending_delegation.has_value()
685 || ctx.pending_pipeline.has_value()) {
686 logger->info("[ITER] terminal state reached during batch "
687 "tool processing — dropping pending {}",
688 ctx.pending_delegation.has_value()
689 ? "delegation" : "pipeline");
690 }
691 return;
692 }
693
694 if (ctx.pending_delegation.has_value()) {
695 ctx.metrics.iterations--;
696 execute_pending_delegation(ctx);
697 } else if (ctx.pending_pipeline.has_value()) {
698 ctx.metrics.iterations--;
699 execute_pending_pipeline(ctx);
700 }
701}
702
711void AgentEngine::evaluate_no_tool_decision(
712 LoopContext& ctx,
713 const std::string& content,
714 const std::string& finish_reason) {
715 if (handle_terminal_finish_reasons(ctx, finish_reason)) { return; }
716 if (try_auto_chain(ctx, finish_reason, content)) {
717 logger->info("[DECISION] auto-chain triggered");
718 return;
719 }
720 if (record_explicit_completion_failure(ctx, finish_reason)) { return; }
721 if (response_generator_.is_response_complete(content, "[]")) {
722 logger->info("[DECISION] response complete");
723 set_state(ctx, AgentState::COMPLETE);
724 }
725}
726
740bool AgentEngine::handle_terminal_finish_reasons(
741 LoopContext& ctx, const std::string& finish_reason) {
742 if (finish_reason == "interrupted") {
743 logger->info("[DECISION] interrupted");
744 set_state(ctx, AgentState::INTERRUPTED);
745 return true;
746 }
747 if (finish_reason == "length") {
748 logger->info("[DECISION] length, continuing");
749 return true;
750 }
751 return false;
752}
753
764bool AgentEngine::record_explicit_completion_failure(
765 LoopContext& ctx, const std::string& finish_reason) {
766 if (!tier_requires_explicit_completion(ctx.locked_tier)) {
767 return false;
768 }
769 auto& retries = ctx.metadata["zero_tool_call_retries"];
770 int n = retries.empty() ? 0 : std::atoi(retries.c_str());
771 logger->warn("[DECISION] tier '{}' requires explicit completion "
772 "but iteration emitted zero tool calls "
773 "(finish_reason={}) retry={}/2",
774 ctx.locked_tier, finish_reason, n);
775 ctx.metadata["failure_reason"] =
776 "zero_tool_calls_with_explicit_completion";
777 ctx.metadata["failure_tier"] = ctx.locked_tier;
778 if (n >= 2) {
779 logger->error("[DECISION] zero-tool-call retries exhausted "
780 "(tier={}), failing turn", ctx.locked_tier);
781 set_state(ctx, AgentState::ERROR);
782 return true;
783 }
784 Message correction;
785 correction.role = "user";
786 correction.content =
787 "[SYSTEM] Your previous response contained no tool call. "
788 "You must end every turn with exactly one tool call "
789 "(entropic.complete, entropic.delegate, or entropic.inspect). "
790 "Retry.";
791 ctx.messages.push_back(std::move(correction));
792 retries = std::to_string(n + 1);
793 set_state(ctx, AgentState::EXECUTING);
794 return true;
795}
796
809bool AgentEngine::tier_requires_explicit_completion(
810 const std::string& tier) const {
811 if (tier_res_.get_tier_param == nullptr) { return false; }
812 auto val = tier_res_.get_tier_param(
813 tier, "explicit_completion", tier_res_.user_data);
814 return val == "true" || val == "1";
815}
816
830bool AgentEngine::is_delegation_cycle(
831 const LoopContext& ctx, const std::string& target) const {
832 if (target == ctx.locked_tier) { return true; }
833 for (const auto& anc : ctx.delegation_ancestor_tiers) {
834 if (anc == target) { return true; }
835 }
836 return false;
837}
838
844bool AgentEngine::is_delegation_repeat_blocked(
845 const LoopContext& ctx, const std::string& target) const {
846 return target == ctx.last_failed_delegation_target
848 >= loop_config_.max_consecutive_failed_delegations;
849}
850
856bool AgentEngine::fold_complete_into_assistant(
857 LoopContext& ctx, const Message& tool_result_msg) const {
858 auto tn_it = tool_result_msg.metadata.find("tool_name");
859 bool is_complete = tn_it != tool_result_msg.metadata.end()
860 && tn_it->second == "entropic.complete";
861 if (!is_complete || ctx.messages.empty()) { return false; }
862 auto& last = ctx.messages.back();
863 auto sum_it = ctx.metadata.find("explicit_completion_summary");
864 bool foldable = last.role == "assistant" && last.content.empty()
865 && sum_it != ctx.metadata.end();
866 if (!foldable) { return false; }
867 last.content = sum_it->second;
868 return true;
869}
870
876int64_t AgentEngine::seconds_since_last_activity() const {
877 auto last = last_activity_epoch_s_.load();
878 if (last == 0) { return 0; }
879 auto now = std::chrono::duration_cast<std::chrono::seconds>(
880 std::chrono::system_clock::now().time_since_epoch()).count();
881 return now > last ? (now - last) : 0;
882}
883
891bool AgentEngine::should_stop(const LoopContext& ctx) const {
892 bool at_limit = ctx.metrics.iterations >= resolve_max_iterations(ctx)
894 return is_terminal_state(ctx) || at_limit;
895}
896
911bool AgentEngine::is_terminal_state(const LoopContext& ctx) {
912 return ctx.state == AgentState::COMPLETE
913 || ctx.state == AgentState::ERROR
914 || ctx.state == AgentState::INTERRUPTED;
915}
916
924void AgentEngine::set_state(LoopContext& ctx, AgentState state) {
925 auto prev = ctx.state;
926 ctx.state = state;
927 logger->info("State: {}", agent_state_name(state));
928 if (callbacks_.on_state_change != nullptr) {
929 callbacks_.on_state_change(
930 static_cast<int>(state), callbacks_.user_data);
931 }
932 // Persistent slot (gh#40 fallout, v2.1.10): the streaming entry
933 // points overwrite callbacks_ via set_callbacks(), so the legacy
934 // on_state_change is silent for streaming runs. The persistent
935 // slot is unaffected by that shuffle.
936 if (state_observer_ != nullptr) {
937 state_observer_(static_cast<int>(state), state_observer_data_);
938 }
939
940 // Hook: ON_STATE_CHANGE (v1.9.1)
941 {
942 std::string json = "{\"previous\":\""
943 + std::string(agent_state_name(prev))
944 + "\",\"current\":\""
945 + std::string(agent_state_name(state)) + "\"}";
946 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_STATE_CHANGE, json.c_str());
947 }
948
949 // Hook: ON_ERROR when entering ERROR state (v1.9.1)
950 if (state == AgentState::ERROR) {
951 std::string json = "{\"error_code\":\"STATE_ERROR\""
952 ",\"iteration\":"
953 + std::to_string(ctx.metrics.iterations)
954 + ",\"consecutive\":" + std::to_string(ctx.consecutive_errors)
955 + "}";
956 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_ERROR, json.c_str());
957 }
958}
959
971void AgentEngine::interrupt() {
972 if (!interrupt_flag_.exchange(true)) {
973 logger->info("Engine interrupted");
974 // P1-10: propagate to external MCP transports so in-flight
975 // tool calls abort alongside the generation loop.
976 if (external_interrupt_cb_ != nullptr) {
977 external_interrupt_cb_(external_interrupt_data_);
978 }
979 }
980 pause_flag_.store(false);
981}
982
995void AgentEngine::set_external_interrupt(void (*cb)(void*),
996 void* user_data) {
997 external_interrupt_cb_ = cb;
998 external_interrupt_data_ = user_data;
999}
1000
1006void AgentEngine::reset_interrupt() {
1007 interrupt_flag_.store(false);
1008}
1009
1015void AgentEngine::pause() {
1016 logger->info("Engine paused");
1017 pause_flag_.store(true);
1018}
1019
1025void AgentEngine::cancel_pause() {
1026 logger->info("Pause cancelled, interrupting");
1027 pause_flag_.store(false);
1028 interrupt_flag_.store(true);
1029}
1030
1038std::pair<int, int> AgentEngine::context_usage(
1039 const std::vector<Message>& messages) const {
1040 int used = token_counter_.count_messages(messages);
1041 return {used, token_counter_.max_tokens};
1042}
1043
1050void AgentEngine::reinject_context_anchors(LoopContext& ctx) {
1051 for (const auto& [key, content] : context_anchors_) {
1052 ContextAnchorDirective d(key, content);
1054 dir_anchor(ctx, d, r);
1055 }
1056}
1057
1058// ── Directive handler registration ───────────────────────
1059
1065void AgentEngine::register_directive_handlers() {
1066 auto reg = [this](entropic_directive_type_t t, auto fn) {
1067 directive_processor_.register_handler(t,
1068 [this, fn](LoopContext& c, const Directive& d,
1069 DirectiveResult& r) {
1070 (this->*fn)(c, d, r);
1071 });
1072 };
1073 reg(ENTROPIC_DIRECTIVE_STOP_PROCESSING, &AgentEngine::dir_stop);
1074 reg(ENTROPIC_DIRECTIVE_TIER_CHANGE, &AgentEngine::dir_tier_change);
1075 reg(ENTROPIC_DIRECTIVE_DELEGATE, &AgentEngine::dir_delegate);
1076 reg(ENTROPIC_DIRECTIVE_PIPELINE, &AgentEngine::dir_pipeline);
1077 reg(ENTROPIC_DIRECTIVE_COMPLETE, &AgentEngine::dir_complete);
1078 reg(ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS,&AgentEngine::dir_clear_todos);
1079 reg(ENTROPIC_DIRECTIVE_INJECT_CONTEXT, &AgentEngine::dir_inject);
1080 reg(ENTROPIC_DIRECTIVE_PRUNE_MESSAGES, &AgentEngine::dir_prune);
1081 reg(ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR, &AgentEngine::dir_anchor);
1082 reg(ENTROPIC_DIRECTIVE_PHASE_CHANGE, &AgentEngine::dir_phase);
1083 reg(ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER,&AgentEngine::dir_notify);
1084}
1085
1086// ── Directive handlers ───────────────────────────────────
1087
1093void AgentEngine::dir_stop(
1094 LoopContext&, const Directive&, DirectiveResult& r) {
1095 logger->info("[DIRECTIVE] stop_processing");
1096 r.stop_processing = true;
1097}
1098
1104void AgentEngine::dir_tier_change(
1105 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1106 const auto& tc = static_cast<const TierChangeDirective&>(d);
1107 ctx.locked_tier = tc.tier;
1108 r.tier_changed = true;
1109 logger->info("[DIRECTIVE] tier_change: {}", tc.tier);
1110}
1111
1117void AgentEngine::dir_delegate(
1118 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1119 const auto& dl = static_cast<const DelegateDirective&>(d);
1120 ctx.pending_delegation = PendingDelegation{
1121 dl.target, dl.task, dl.max_turns,
1122 dl.resume_from_delegation_id};
1123 r.stop_processing = true;
1124 if (dl.resume_from_delegation_id.empty()) {
1125 logger->info("[DIRECTIVE] delegate: target={} task='{}'",
1126 dl.target, dl.task);
1127 } else {
1128 // gh#32 (v2.1.6): target will be resolved from storage later.
1129 logger->info("[DIRECTIVE] resume_delegation: id={} task='{}'",
1130 dl.resume_from_delegation_id, dl.task);
1131 }
1132}
1133
1139void AgentEngine::dir_pipeline(
1140 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1141 const auto& pl = static_cast<const PipelineDirective&>(d);
1142 ctx.pending_pipeline = PendingPipeline{pl.stages, pl.task};
1143 r.stop_processing = true;
1144 logger->info("[DIRECTIVE] pipeline: {} stages", pl.stages.size());
1145}
1146
1157void AgentEngine::dir_complete(
1158 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
1159 const auto& cd = static_cast<const CompleteDirective&>(d);
1160
1161 // P1-5 follow-up (2.0.6-rc16.2): signal validating phase so async
1162 // subscribers can progress queued/running/running:<tier> → validating.
1163 auto prior_state = ctx.state;
1164 set_state(ctx, AgentState::VERIFYING);
1165
1166 // Fire ON_COMPLETE pre-hook — application can validate/reject
1167 if (fire_complete_hook(cd.summary, ctx)) {
1168 logger->info("[DIRECTIVE] complete REJECTED by hook → revising");
1169 // Revision path: inform subscribers the validator is rewriting.
1170 ctx.metadata["validator_phase"] = "revising";
1171 set_state(ctx, prior_state);
1172 r.stop_processing = false; // don't stop — model should retry
1173 return;
1174 }
1175
1176 ctx.metadata["explicit_completion_summary"] = cd.summary;
1177 // Issue #10 (v2.1.4): persist coverage_gap signal in metadata so
1178 // delegation.cpp::build_child_result can hoist it onto the
1179 // DelegationResult that flows back to the parent. The metadata
1180 // path is the only channel that survives between the child loop
1181 // (which sees the directive) and the parent's
1182 // finalize_delegation_result (which reads the result).
1183 if (cd.coverage_gap) {
1184 ctx.metadata["coverage_gap"] = "true";
1185 ctx.metadata["gap_description"] = cd.gap_description;
1186 nlohmann::json files_arr = cd.suggested_files;
1187 ctx.metadata["suggested_files_json"] = files_arr.dump();
1188 }
1189 set_state(ctx, AgentState::COMPLETE);
1190 r.stop_processing = true;
1191 logger->info("[DIRECTIVE] complete coverage_gap={}",
1192 cd.coverage_gap);
1193}
1194
1200void AgentEngine::dir_clear_todos(
1201 LoopContext&, const Directive&, DirectiveResult&) {
1202 logger->debug("[DIRECTIVE] clear_self_todos (no-op)");
1203}
1204
1210void AgentEngine::dir_inject(
1211 LoopContext&, const Directive& d, DirectiveResult& r) {
1212 const auto& ic = static_cast<const InjectContextDirective&>(d);
1213 if (!ic.content.empty()) {
1214 Message msg;
1215 msg.role = ic.role;
1216 msg.content = ic.content;
1217 r.injected_messages.push_back(std::move(msg));
1218 logger->info("[DIRECTIVE] inject_context");
1219 }
1220}
1221
1227void AgentEngine::dir_prune(
1228 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1229 const auto& pm = static_cast<const PruneMessagesDirective&>(d);
1230 auto [pruned, freed] = context_manager_.prune_tool_results(
1231 ctx, pm.keep_recent);
1232 logger->info("[DIRECTIVE] prune: {} results, {} chars", pruned, freed);
1233}
1234
1240void AgentEngine::dir_anchor(
1241 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1242 const auto& ca = static_cast<const ContextAnchorDirective&>(d);
1243 if (ca.content.empty()) {
1244 context_anchors_.erase(ca.key);
1245 remove_anchor_messages(ctx, ca.key);
1246 logger->info("Removed anchor: {}", ca.key);
1247 return;
1248 }
1249 context_anchors_[ca.key] = ca.content;
1250 remove_anchor_messages(ctx, ca.key);
1251 Message anchor;
1252 anchor.role = "user";
1253 anchor.content = ca.content;
1254 anchor.metadata["is_context_anchor"] = "true";
1255 anchor.metadata["anchor_key"] = ca.key;
1256 ctx.messages.push_back(std::move(anchor));
1257 logger->info("Updated anchor: {}", ca.key);
1258}
1259
1265void AgentEngine::dir_phase(
1266 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1267 const auto& pc = static_cast<const PhaseChangeDirective&>(d);
1268 ctx.active_phase = pc.phase;
1269 logger->info("[DIRECTIVE] phase_change: {}", pc.phase);
1270}
1271
1277void AgentEngine::dir_notify(
1278 LoopContext&, const Directive& d, DirectiveResult&) {
1279 const auto& np = static_cast<const NotifyPresenterDirective&>(d);
1280 if (callbacks_.on_presenter_notify != nullptr) {
1281 callbacks_.on_presenter_notify(
1282 np.key.c_str(), np.data_json.c_str(),
1283 callbacks_.user_data);
1284 }
1285}
1286
1287// ── Tool call parsing (v1.8.5) ───────────────────────────
1288
1305 const nlohmann::json& obj, const std::string& tc_str) {
1306 ToolCall tc;
1307 tc.name = obj.value("name", "");
1308 tc.id = "tc-" + std::to_string(
1309 std::hash<std::string>{}(tc.name + tc_str) & 0xFFFF);
1310 if (obj.contains("arguments") && obj["arguments"].is_object()) {
1311 tc.arguments_json = obj["arguments"].dump();
1312 for (auto& [k, v] : obj["arguments"].items()) {
1313 tc.arguments[k] = v.is_string()
1314 ? v.get<std::string>() : v.dump();
1315 }
1316 }
1317 return tc;
1318}
1319
1327static std::vector<ToolCall> decode_tool_calls_json(
1328 const std::string& tc_str) {
1329 std::vector<ToolCall> calls;
1330 if (tc_str == "[]" || tc_str.empty()) { return calls; }
1331 auto arr = nlohmann::json::parse(tc_str, nullptr, false);
1332 if (!arr.is_array()) { return calls; }
1333 for (const auto& obj : arr) {
1334 calls.push_back(build_tool_call_from_json(obj, tc_str));
1335 }
1336 return calls;
1337}
1338
1351std::pair<std::string, std::vector<ToolCall>>
1352AgentEngine::parse_tool_calls(const std::string& raw_content) {
1353 if (inference_.parse_tool_calls == nullptr) {
1354 return {raw_content, {}};
1355 }
1356
1357 char* cleaned = nullptr;
1358 char* tc_json = nullptr;
1359 int rc = inference_.parse_tool_calls(
1360 raw_content.c_str(), &cleaned, &tc_json,
1361 inference_.adapter_data);
1362
1363 std::string cleaned_str = cleaned ? cleaned : raw_content;
1364 std::string tc_str = tc_json ? tc_json : "[]";
1365
1366 if (inference_.free_fn != nullptr) {
1367 if (cleaned != nullptr) { inference_.free_fn(cleaned); }
1368 if (tc_json != nullptr) { inference_.free_fn(tc_json); }
1369 }
1370
1371 if (rc != 0) { return {cleaned_str, {}}; }
1372 auto calls = decode_tool_calls_json(tc_str);
1373 if (!calls.empty()) {
1374 logger->info("Parsed tool calls from model output");
1375 }
1376 return {cleaned_str, std::move(calls)};
1377}
1378
1384void AgentEngine::defang_meta_action_envelope(Message& msg) const {
1385 auto tn = msg.metadata.find("tool_name");
1386 bool is_meta = tn != msg.metadata.end()
1387 && tn->second.rfind("entropic.", 0) == 0;
1388 if (!is_meta) { return; }
1389 auto j = nlohmann::json::parse(msg.content, nullptr, false);
1390 bool is_envelope = j.is_object() && j.contains("action")
1391 && j["action"].is_string();
1392 if (!is_envelope) { return; }
1393 msg.content = "(engine: " + j["action"].get<std::string>()
1394 + " directive accepted)";
1395}
1396
1405bool AgentEngine::process_tool_results(
1406 LoopContext& ctx,
1407 const std::vector<ToolCall>& tool_calls) {
1408 set_state(ctx, AgentState::WAITING_TOOL);
1409
1410 auto results = tool_exec_.process_tool_calls(
1411 ctx, tool_calls, tool_exec_.user_data);
1412
1413 logger->info("[TOOLS] {} call(s) -> {} result message(s)",
1414 tool_calls.size(), results.size());
1415
1416 // gh#84 (v2.5.1): "progress" = at least one tool genuinely
1417 // executed (result_kind ok / ok_empty), not merely parsed. The
1418 // thinking budget resets only on progress so a duplicate/rejected
1419 // -spam spiral (rejected_duplicate, rejected_schema, anti-spiral)
1420 // can no longer keep its budget perpetually fresh.
1421 bool made_progress = false;
1422 for (auto& msg : results) {
1423 auto it = msg.metadata.find("result_kind");
1424 if (it != msg.metadata.end()
1425 && (it->second == "ok" || it->second == "ok_empty")) {
1426 made_progress = true;
1427 }
1428 if (fold_complete_into_assistant(ctx, msg)) {
1429 continue; // gh#68: folded; skip pushing JSON user message
1430 }
1431 defang_meta_action_envelope(msg); // gh#88: strip call-shaped envelope
1432 ctx.messages.push_back(std::move(msg));
1433 }
1434
1435 ctx.has_pending_tool_results = true;
1436 // Don't overwrite terminal states set by directive handlers
1437 if (!is_terminal_state(ctx)) {
1438 set_state(ctx, AgentState::EXECUTING);
1439 }
1440 return made_progress;
1441}
1442
1443// ── Anchor helper ────────────────────────────────────────
1444
1453 const std::string& key) {
1454 auto& msgs = ctx.messages;
1455 msgs.erase(
1456 std::remove_if(msgs.begin(), msgs.end(),
1457 [&key](const Message& m) {
1458 auto it = m.metadata.find("anchor_key");
1459 return it != m.metadata.end() && it->second == key;
1460 }),
1461 msgs.end());
1462}
1463
1464// ── Hook helpers (v1.9.1) ────────────────────────────────
1465
1474bool AgentEngine::fire_pre_hook(
1475 entropic_hook_point_t point, int iteration) {
1476 if (hooks_.fire_pre == nullptr) {
1477 return false;
1478 }
1479 std::string json = "{\"iteration\":"
1480 + std::to_string(iteration) + "}";
1481 char* modified = nullptr;
1482 int rc = hooks_.fire_pre(hooks_.registry,
1483 point, json.c_str(), &modified);
1484 free(modified);
1485 return rc != 0;
1486}
1487
1498static const std::array<const char*, 256>& json_escape_table() {
1499 static const auto tbl = []() {
1500 std::array<const char*, 256> t{};
1501 t[static_cast<unsigned char>('"')] = "\\\"";
1502 t[static_cast<unsigned char>('\\')] = "\\\\";
1503 t[static_cast<unsigned char>('\n')] = "\\n";
1504 t[static_cast<unsigned char>('\r')] = "\\r";
1505 t[static_cast<unsigned char>('\t')] = "\\t";
1506 return t;
1507 }();
1508 return tbl;
1509}
1510
1518static std::string json_escape_engine(const std::string& s) {
1519 const auto& tbl = json_escape_table();
1520 std::string out;
1521 out.reserve(s.size() + 16);
1522 for (char c : s) {
1523 const char* esc = tbl[static_cast<unsigned char>(c)];
1524 if (esc) { out += esc; }
1525 else { out += c; }
1526 }
1527 return out;
1528}
1529
1541static std::string build_tool_manifest(
1542 const std::vector<Message>& messages) {
1543 std::string manifest;
1544 for (const auto& msg : messages) {
1545 auto it = msg.metadata.find("tool_name");
1546 if (it == msg.metadata.end() || it->second.empty()) {
1547 continue;
1548 }
1549 // Issue #5 (v2.1.3): when prune has stubbed this message, the
1550 // original (pre-stub) content size is preserved in
1551 // metadata["original_content"]. Report the real size so the
1552 // validator sees accurate evidence sizes instead of the stub
1553 // length (which is always ~50 chars regardless of original).
1554 auto orig = msg.metadata.find("original_content");
1555 size_t real_size = (orig != msg.metadata.end())
1556 ? orig->second.size()
1557 : msg.content.size();
1558 manifest += "- " + it->second
1559 + " \xe2\x86\x92 " + std::to_string(real_size)
1560 + " chars\n";
1561 }
1562 return manifest;
1563}
1564
1573static std::string format_tool_evidence_entry(const Message& msg,
1574 size_t chars_per_result) {
1575 auto orig = msg.metadata.find("original_content");
1576 const std::string& src = (orig != msg.metadata.end())
1577 ? orig->second
1578 : msg.content;
1579 auto iter = msg.metadata.find("added_at_iteration");
1580 std::string out = "## ";
1581 out += msg.metadata.at("tool_name");
1582 if (iter != msg.metadata.end()) {
1583 out += " [iter " + iter->second + "]";
1584 }
1585 out += "\n";
1586 if (src.size() <= chars_per_result) {
1587 out += src;
1588 } else {
1589 out += src.substr(0, chars_per_result);
1590 out += "\n[... truncated, "
1591 + std::to_string(src.size() - chars_per_result)
1592 + " more chars]";
1593 }
1594 out += "\n\n";
1595 return out;
1596}
1597
1625static std::string build_tool_evidence(
1626 const std::vector<Message>& messages,
1627 size_t max_results,
1628 size_t chars_per_result) {
1629 std::vector<const Message*> tool_msgs;
1630 for (const auto& msg : messages) {
1631 auto it = msg.metadata.find("tool_name");
1632 if (it == msg.metadata.end() || it->second.empty()) {
1633 continue;
1634 }
1635 tool_msgs.push_back(&msg);
1636 }
1637 if (tool_msgs.empty()) { return {}; }
1638
1639 size_t elided = (tool_msgs.size() > max_results)
1640 ? (tool_msgs.size() - max_results) : 0;
1641 size_t start = elided;
1642 std::string out;
1643 if (elided > 0) {
1644 out += "(" + std::to_string(elided)
1645 + " earlier tool results elided for length)\n";
1646 }
1647 for (size_t i = start; i < tool_msgs.size(); ++i) {
1648 out += format_tool_evidence_entry(*tool_msgs[i], chars_per_result);
1649 }
1650 return out;
1651}
1652
1668 const std::string& base,
1669 const ToolExecutionInterface& tool_exec) {
1670 if (tool_exec.history_json == nullptr) { return base; }
1671 char* js = tool_exec.history_json(20, tool_exec.user_data);
1672 if (js == nullptr) { return base; }
1673 std::string out = base + "prior-iteration history: " + js + "\n";
1674 if (tool_exec.free_fn != nullptr) { tool_exec.free_fn(js); }
1675 return out;
1676}
1677
1685static std::string extract_system_prompt(
1686 const std::vector<Message>& messages) {
1687 for (const auto& msg : messages) {
1688 if (msg.role == "system") { return msg.content; }
1689 }
1690 return {};
1691}
1692
1708void AgentEngine::fire_post_generate_hook(
1709 GenerateResult& result,
1710 const std::string& tier,
1711 const std::vector<Message>& messages) {
1712 if (hooks_.fire_post == nullptr) {
1713 return;
1714 }
1715 auto manifest = build_tool_manifest(messages);
1716 manifest = enrich_manifest_with_history(manifest, tool_exec_);
1717 auto sys = extract_system_prompt(messages);
1718 // Issue #5 (v2.1.3): include un-pruned tool-result evidence so the
1719 // constitutional validator can verify file:line citations against
1720 // actual content rather than the stubs that prune leaves behind.
1721 // Bounded at 20 most-recent results × 800 chars each (~16KB max
1722 // evidence), well within any sane critique-prompt budget. The
1723 // field is OPTIONAL on the hook contract — pre-2.1.3 consumers
1724 // ignore it; v2.1.3 validator augments its critique prompt.
1725 auto evidence = build_tool_evidence(messages, 20, 800);
1726 std::string json =
1727 "{\"finish_reason\":\"" + result.finish_reason
1728 + "\",\"content\":\"" + json_escape_engine(result.content)
1729 + "\",\"tier\":\"" + tier
1730 + "\",\"tool_context\":\"" + json_escape_engine(manifest)
1731 + "\",\"tool_evidence\":\"" + json_escape_engine(evidence)
1732 + "\",\"system_prompt\":\"" + json_escape_engine(sys) + "\"}";
1733 char* out = nullptr;
1734 hooks_.fire_post(hooks_.registry,
1735 ENTROPIC_HOOK_POST_GENERATE, json.c_str(), &out);
1736 if (out != nullptr) {
1737 result.content = out;
1738 logger->info("POST_GENERATE hook revised content");
1739 free(out);
1740 }
1741}
1742
1763void AgentEngine::dispatch_post_generate(
1764 LoopContext& ctx, GenerateResult& result) {
1765 // The per-turn reminders built from pending_validation_feedback
1766 // and pending_anti_spiral_warning were just consumed by
1767 // generate_response — clear both so neither leaks into a later
1768 // iteration. (#42, #45 — both one-shot.)
1769 ctx.pending_validation_feedback.clear();
1770 ctx.pending_anti_spiral_warning.clear();
1771 fire_post_generate_hook(result, ctx.locked_tier, ctx.messages);
1772 // After POST_GENERATE the validator may have rejected — stash
1773 // its reason for the NEXT iteration's system prompt.
1774 capture_validation_feedback(ctx);
1775}
1776
1782void AgentEngine::capture_validation_feedback(LoopContext& ctx) {
1783 if (validation_provider_ == nullptr) { return; }
1784 char* v = validation_provider_(validation_provider_data_);
1785 if (v == nullptr) { return; }
1786 std::string raw(v);
1787 free(v);
1788 try {
1789 auto j = nlohmann::json::parse(raw);
1790 auto verdict = j.value("verdict", "");
1791 if (verdict.rfind("rejected", 0) != 0) { return; }
1792 std::string joined;
1793 if (j.contains("violations") && j["violations"].is_array()) {
1794 for (const auto& vio : j["violations"]) {
1795 if (!joined.empty()) { joined += "; "; }
1796 joined += vio.is_string()
1797 ? vio.get<std::string>()
1798 : vio.dump();
1799 }
1800 }
1801 if (joined.empty()) { joined = verdict; }
1802 ctx.pending_validation_feedback = joined;
1803 } catch (const nlohmann::json::exception&) {
1804 // Malformed provider output is non-fatal; just skip.
1805 }
1806}
1807
1819static std::string build_tool_results_json(
1820 const std::vector<Message>& messages) {
1821 std::string arr = "[";
1822 bool first = true;
1823 for (const auto& msg : messages) {
1824 auto it = msg.metadata.find("tool_name");
1825 if (it == msg.metadata.end() || it->second.empty()) {
1826 continue;
1827 }
1828 if (!first) { arr += ","; }
1829 arr += "{\"name\":\"" + json_escape_engine(it->second)
1830 + "\",\"content\":\"" + json_escape_engine(msg.content)
1831 + "\"}";
1832 first = false;
1833 }
1834 arr += "]";
1835 return arr;
1836}
1837
1858bool AgentEngine::fire_complete_hook(
1859 const std::string& summary,
1860 const LoopContext& ctx) {
1861 if (hooks_.fire_pre == nullptr) { return false; }
1862
1863 auto tool_results = build_tool_results_json(ctx.messages);
1864 // E3 (2.0.6-rc17): splice validator verdict/violations so
1865 // ON_COMPLETE consumers can distinguish clean pass from
1866 // reverted-for-length / max-revisions-exhausted.
1867 std::string validation_block = ",\"validation\":null";
1868 if (validation_provider_ != nullptr) {
1869 char* v = validation_provider_(validation_provider_data_);
1870 if (v != nullptr) {
1871 validation_block = ",\"validation\":" + std::string(v);
1872 free(v);
1873 }
1874 }
1875 std::string json =
1876 "{\"summary\":\"" + json_escape_engine(summary)
1877 + "\",\"tier\":\"" + ctx.locked_tier
1878 + "\",\"tool_results\":" + tool_results
1879 + ",\"iteration\":" + std::to_string(ctx.metrics.iterations)
1880 + validation_block
1881 + "}";
1882 char* modified = nullptr;
1883 int rc = hooks_.fire_pre(hooks_.registry,
1884 ENTROPIC_HOOK_ON_COMPLETE, json.c_str(), &modified);
1885 if (modified != nullptr) {
1886 // Hook provided rejection feedback — inject as user message
1887 Message feedback;
1888 feedback.role = "user";
1889 feedback.content = std::string("[CITATION VALIDATION] ") + modified;
1890 const_cast<LoopContext&>(ctx).messages.push_back(
1891 std::move(feedback));
1892 free(modified);
1893 }
1894 return rc != 0;
1895}
1896
1905bool AgentEngine::fire_delegate_pre_hook(
1906 const PendingDelegation& pending, int depth) {
1907 if (hooks_.fire_pre == nullptr) {
1908 return false;
1909 }
1910 std::string json = "{\"target_tier\":\""
1911 + pending.target + "\",\"task\":\""
1912 + pending.task + "\",\"depth\":"
1913 + std::to_string(depth) + "}";
1914 char* modified = nullptr;
1915 int rc = hooks_.fire_pre(hooks_.registry,
1916 ENTROPIC_HOOK_ON_DELEGATE, json.c_str(), &modified);
1917 free(modified);
1918 return rc != 0;
1919}
1920
1937void AgentEngine::fire_delegate_complete_hook(
1938 const std::string& target, bool success,
1939 const std::string& summary) {
1940 if (hooks_.fire_post == nullptr) {
1941 return;
1942 }
1943 nlohmann::json j;
1944 j["target_tier"] = target;
1945 j["success"] = success;
1946 j["result_kind"] = result_kind_to_string(success
1947 ? ToolResultKind::ok
1949 j["summary"] = summary;
1950 std::string json = j.dump();
1951 char* out = nullptr;
1952 hooks_.fire_post(hooks_.registry,
1953 ENTROPIC_HOOK_ON_DELEGATE_COMPLETE, json.c_str(), &out);
1954 free(out);
1955}
1956
1957// ── Delegation execution (v1.8.6) ────────────────────────
1958
1966static void run_child_loop_trampoline(LoopContext& ctx, void* user_data) {
1967 auto* engine = static_cast<AgentEngine*>(user_data);
1968 // gh#81 (v2.4.3): inherit the parent's interrupt — a child loop
1969 // must not clear a parent interrupt raised at/just-before dispatch.
1970 engine->run_loop(ctx, /*inherit_interrupt=*/true);
1971}
1972
1980 Message reject;
1981 reject.role = "user";
1982 reject.content = "[DELEGATION REJECTED] Maximum delegation "
1983 "depth (" + std::to_string(
1984 AgentEngine::MAX_DELEGATION_DEPTH) +
1985 ") reached.";
1986 ctx.messages.push_back(std::move(reject));
1987}
1988
2000 LoopContext& ctx, const std::string& target) {
2001 std::string chain;
2002 for (const auto& anc : ctx.delegation_ancestor_tiers) {
2003 chain += anc + " -> ";
2004 }
2005 chain += ctx.locked_tier + " -> " + target;
2006
2007 Message reject;
2008 reject.role = "user";
2009 reject.content = "[DELEGATION REJECTED] Circular delegation "
2010 "detected: tier '" + target +
2011 "' already in the active delegation chain "
2012 "(" + chain + "). Choose a different target.";
2013 ctx.metadata["failure_reason"] = "delegation_cycle";
2014 ctx.metadata["failure_target"] = target;
2015 ctx.messages.push_back(std::move(reject));
2016}
2017
2027 const std::string& target, const DelegationResult& result) {
2028 std::string tag = result.success ? "COMPLETE" : "FAILED";
2029 Message msg;
2030 msg.role = "user";
2031 msg.content = "[DELEGATION " + tag + ": " + target + "] " + result.summary;
2032 ctx.messages.push_back(std::move(msg));
2033}
2034
2047 LoopContext& ctx, const std::string& target, int n) {
2048 Message reject;
2049 reject.role = "user";
2050 reject.content = "[DELEGATION REJECTED] '" + target
2051 + "' has just failed " + std::to_string(n)
2052 + " times in a row. Stop retrying this target. Either "
2053 "respond to the user with what you have, or delegate to "
2054 "a different tier.";
2055 ctx.metadata["failure_reason"] = "delegation_repeat_blocked";
2056 ctx.metadata["failure_target"] = target;
2057 ctx.messages.push_back(std::move(reject));
2058}
2059
2068bool AgentEngine::reject_delegation_if_guarded(
2069 LoopContext& ctx, const PendingDelegation& pending) {
2070 bool rejected = true;
2071 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2072 logger->warn("Delegation rejected: depth {} >= max {}",
2073 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2074 push_delegation_rejected(ctx);
2075 } else if (is_delegation_cycle(ctx, pending.target)) {
2076 // P1-9: reject A→B→A and longer cycles before running a child.
2077 logger->warn("Delegation rejected: cycle on target tier '{}'",
2078 pending.target);
2079 push_delegation_cycle_rejected(ctx, pending.target);
2080 } else if (is_delegation_repeat_blocked(ctx, pending.target)) {
2081 // gh#64: refuse re-delegation to a target that has just failed
2082 // N times in a row. Pre-fix, a lead retrying a Q4 specialist
2083 // that couldn't emit a tool call would burn 30+ identical
2084 // delegations before its own iteration cap stopped it.
2085 logger->warn("Delegation rejected: '{}' failed {}x in a row "
2086 "(>= max_consecutive_failed_delegations={})",
2087 pending.target,
2088 ctx.consecutive_failed_delegations,
2089 loop_config_.max_consecutive_failed_delegations);
2091 ctx, pending.target, ctx.consecutive_failed_delegations);
2092 } else {
2093 rejected = false;
2094 }
2095 return rejected;
2096}
2097
2110void AgentEngine::execute_pending_delegation(LoopContext& ctx) {
2111 auto pending = std::move(*ctx.pending_delegation);
2112 ctx.pending_delegation.reset();
2113
2114 if (reject_delegation_if_guarded(ctx, pending)) { return; }
2115
2116 set_state(ctx, AgentState::DELEGATING);
2117
2118 // gh#32 (v2.1.6): resume_delegation tool emits a directive with
2119 // resume_from_delegation_id set; the target tier is unknown to the
2120 // tool and must be resolved from storage. Failure to load (unknown
2121 // id, no storage) surfaces a typed error to the lead.
2122 //
2123 // The pre-hook gate is interleaved with resume so both share a
2124 // single early-exit (knots returns gate).
2125 std::vector<Message> resume_history;
2126 bool blocked = false;
2127 if (!pending.resume_from_delegation_id.empty()
2128 && !resolve_resume_delegation(ctx, pending, resume_history)) {
2129 blocked = true;
2130 } else if (fire_delegate_pre_hook(pending, ctx.delegation_depth)) {
2131 logger->info("ON_DELEGATE hook cancelled delegation");
2132 blocked = true;
2133 }
2134 if (blocked) {
2135 set_state(ctx, AgentState::EXECUTING);
2136 return;
2137 }
2138
2139 fire_delegation_start(ctx, pending.target, pending.task);
2140 auto result = run_pending_delegation(
2141 ctx, pending, std::move(resume_history));
2142 push_delegation_result(ctx, pending.target, result);
2143
2144 // gh#64: track consecutive failures against the same target.
2145 if (result.success) {
2146 ctx.last_failed_delegation_target.clear();
2147 ctx.consecutive_failed_delegations = 0;
2148 } else if (pending.target == ctx.last_failed_delegation_target) {
2149 ++ctx.consecutive_failed_delegations;
2150 } else {
2151 ctx.last_failed_delegation_target = pending.target;
2152 ctx.consecutive_failed_delegations = 1;
2153 }
2154
2155 fire_delegation_complete(ctx, pending.target, result);
2156 fire_delegate_complete_hook(pending.target, result.success,
2157 result.summary);
2158
2159 finalize_delegation_result(ctx, result);
2160}
2161
2174void AgentEngine::relay_partial_result(
2175 LoopContext& ctx, const std::string& summary) {
2176 GenerateResult relay_result;
2177 relay_result.content = summary;
2178 relay_result.finish_reason = "stop";
2179 relay_result.tool_calls_json = "[]";
2180 fire_post_generate_hook(
2181 relay_result, ctx.locked_tier, ctx.messages);
2182 ctx.metadata["explicit_completion_summary"] = relay_result.content;
2183 set_state(ctx, AgentState::COMPLETE);
2184}
2185
2193 const std::string& tier, const DelegationResult& result) {
2194 std::string body =
2195 "[COVERAGE GAP from " + tier + "]\n"
2196 "Summary so far: " + result.summary + "\n"
2197 "What's missing: " + result.gap_description;
2198 if (!result.suggested_files.empty()) {
2199 body += "\nSuggested files to inspect:";
2200 for (const auto& f : result.suggested_files) {
2201 body += "\n - " + f;
2202 }
2203 }
2204 return body;
2205}
2206
2227void AgentEngine::finalize_delegation_result(
2228 LoopContext& ctx, const DelegationResult& result) {
2229 // delegation.cpp sets success=false when terminal_reason is non-empty,
2230 // so the two relay branches are disjoint — check them independently.
2231 const bool in_relay_tier =
2232 relay_single_delegate_tiers_.count(ctx.locked_tier) > 0;
2233 if (in_relay_tier && result.coverage_gap) {
2234 Message gap;
2235 gap.role = "user";
2236 gap.content = build_coverage_gap_message(
2237 result.target_tier, result);
2238 ctx.messages.push_back(std::move(gap));
2239 ctx.metadata["relay_status"] = "coverage_gap_suppressed";
2240 logger->info(
2241 "[COVERAGE GAP] suppressing auto-relay for tier={} "
2242 "({} suggested files)",
2243 result.target_tier, result.suggested_files.size());
2244 set_state(ctx, AgentState::EXECUTING);
2245 return;
2246 }
2247 if (result.success && in_relay_tier) {
2248 relay_partial_result(ctx, result.summary);
2249 log_relay_status(ctx);
2250 return;
2251 }
2252 if (!result.terminal_reason.empty() && in_relay_tier) {
2253 relay_partial_result(ctx,
2254 "[partial — budget_exhausted] " + result.summary);
2255 log_relay_status(ctx, result.terminal_reason);
2256 return;
2257 }
2258 bool needs_explicit = tier_requires_explicit_completion(
2259 ctx.locked_tier);
2260 set_state(ctx, (result.success && !needs_explicit)
2261 ? AgentState::COMPLETE : AgentState::EXECUTING);
2262}
2263
2278void AgentEngine::log_relay_status(LoopContext& ctx,
2279 const std::string& terminal_reason) {
2280 std::string verdict;
2281 if (validation_provider_ != nullptr) {
2282 char* v = validation_provider_(validation_provider_data_);
2283 if (v != nullptr) {
2284 std::string jv(v);
2285 free(v);
2286 auto pos = jv.find("\"verdict\":\"");
2287 if (pos != std::string::npos) {
2288 pos += 11;
2289 auto end = jv.find('"', pos);
2290 if (end != std::string::npos) {
2291 verdict = jv.substr(pos, end - pos);
2292 }
2293 }
2294 }
2295 }
2296 if (!terminal_reason.empty()) {
2297 ctx.metadata["relay_status"] = "budget_exhausted_relayed";
2298 logger->warn(
2299 "Relay: single-delegate result used "
2300 "(partial — terminal_reason={}, verdict={})",
2301 terminal_reason, verdict.empty() ? "none" : verdict);
2302 return;
2303 }
2304 if (verdict == "skipped" || verdict.empty()) {
2305 ctx.metadata["relay_status"] = "validation_skipped";
2306 logger->info(
2307 "Relay: single-delegate result used "
2308 "(lead validation skipped per config)");
2309 } else {
2310 ctx.metadata["relay_status"] = "validated";
2311 logger->info(
2312 "Relay: single-delegate result used "
2313 "(passed lead validation)");
2314 }
2315}
2316
2323void AgentEngine::execute_pending_pipeline(LoopContext& ctx) {
2324 auto pending = std::move(*ctx.pending_pipeline);
2325 ctx.pending_pipeline.reset();
2326
2327 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2328 logger->warn("Pipeline rejected: depth {} >= max {}",
2329 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2330 Message reject;
2331 reject.role = "user";
2332 reject.content = "[PIPELINE REJECTED] Maximum delegation "
2333 "depth reached.";
2334 ctx.messages.push_back(std::move(reject));
2335 return;
2336 }
2337
2338 set_state(ctx, AgentState::DELEGATING);
2339
2340 // gh#33 (v2.1.6): engine-scoped sandbox; non-owning pointer.
2341 auto repo_dir = get_repo_dir();
2342 DelegationManager mgr(run_child_loop_trampoline, this,
2343 tier_res_, repo_dir,
2344 ensure_sandbox_manager());
2345 if (storage_.create_delegation != nullptr) {
2346 mgr.set_storage(&storage_);
2347 }
2348 // gh#29 (v2.1.5): snapshot under the mutex (see execute_pending_
2349 // delegation comment); avoids a torn read against a concurrent
2350 // set_delegation_callbacks call.
2351 auto cb_snap = delegation_callbacks_snapshot();
2352 mgr.set_delegation_callbacks(
2353 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2354 auto result = mgr.execute_pipeline(
2355 ctx, pending.stages, pending.task);
2356
2357 std::string tag = result.success ? "COMPLETE" : "FAILED";
2358 Message result_msg;
2359 result_msg.role = "user";
2360 result_msg.content = "[PIPELINE " + tag + "] " + result.summary;
2361 ctx.messages.push_back(std::move(result_msg));
2362
2363 set_state(ctx, AgentState::EXECUTING);
2364}
2365
2374void AgentEngine::fire_delegation_start(
2375 const LoopContext& /*ctx*/,
2376 const std::string& tier,
2377 const std::string& task) {
2378 if (callbacks_.on_delegation_start != nullptr) {
2379 callbacks_.on_delegation_start(
2380 "", tier.c_str(), task.c_str(),
2381 callbacks_.user_data);
2382 }
2383}
2384
2393void AgentEngine::fire_delegation_complete(
2394 const LoopContext& /*ctx*/,
2395 const std::string& tier,
2396 const DelegationResult& result) {
2397 if (callbacks_.on_delegation_complete != nullptr) {
2398 callbacks_.on_delegation_complete(
2399 "", tier.c_str(), result.summary.c_str(),
2400 result.success ? 1 : 0,
2401 callbacks_.user_data);
2402 }
2403}
2404
2405// ── Auto-chain (v1.8.6) ─────────────────────────────────
2406
2416bool AgentEngine::should_auto_chain(
2417 const LoopContext& ctx,
2418 const std::string& finish_reason,
2419 const std::string& content) {
2420 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
2421 return false;
2422 }
2423
2424 std::string auto_chain = tier_res_.get_tier_param(
2425 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2426 if (auto_chain.empty()) {
2427 return false;
2428 }
2429
2430 bool triggered = (finish_reason == "length") ||
2431 (finish_reason == "stop" &&
2432 response_generator_.is_response_complete(content, "[]"));
2433 return triggered;
2434}
2435
2445bool AgentEngine::try_auto_chain(
2446 LoopContext& ctx,
2447 const std::string& finish_reason,
2448 const std::string& content) {
2449 if (!should_auto_chain(ctx, finish_reason, content)) {
2450 return false;
2451 }
2452
2453 if (ctx.delegation_depth > 0) {
2454 logger->info("[AUTO-CHAIN] child depth={}, completing",
2455 ctx.delegation_depth);
2456 set_state(ctx, AgentState::COMPLETE);
2457 return true;
2458 }
2459
2460 // Root: tier change to auto_chain target
2461 std::string target = tier_res_.get_tier_param(
2462 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2463
2464 if (!target.empty()) {
2465 logger->info("[AUTO-CHAIN] root, tier change to '{}'", target);
2466 TierChangeDirective tc(target, "auto_chain");
2467 DirectiveResult r;
2468 dir_tier_change(ctx, tc, r);
2469 }
2470 return !target.empty();
2471}
2472
2473// ── Project dir (v2.1.5, gh#29) ─────────────────────────
2474
2495std::filesystem::path AgentEngine::get_repo_dir() {
2496 if (repo_dir_checked_) {
2497 return cached_repo_dir_.value_or(std::filesystem::path{});
2498 }
2499 repo_dir_checked_ = true;
2500 std::filesystem::path resolved;
2501 if (!project_dir_override_.empty()) {
2502 resolved = project_dir_override_;
2503 logger->info("Project dir for sandbox snapshots: {} (configured)",
2504 resolved.string());
2505 } else {
2506 resolved = std::filesystem::current_path();
2507 logger->info("Project dir for sandbox snapshots: {} (cwd fallback)",
2508 resolved.string());
2509 }
2510 cached_repo_dir_ = resolved;
2511 return resolved;
2512}
2513
2525void AgentEngine::set_project_dir(const std::filesystem::path& project_dir) {
2526 project_dir_override_ = project_dir;
2527 cached_repo_dir_.reset();
2528 repo_dir_checked_ = false;
2529}
2530
2547 LoopContext& ctx,
2548 const std::string& reason,
2549 const std::string& delegation_id) {
2550 Message m;
2551 m.role = "user";
2552 m.content = "[DELEGATION FAILED: resume_delegation] "
2553 + reason + " (delegation_id=" + delegation_id + ")";
2554 ctx.messages.push_back(std::move(m));
2555}
2556
2570bool AgentEngine::fetch_resume_payload(
2571 LoopContext& ctx,
2572 const std::string& id,
2573 nlohmann::json& parsed) {
2574 std::optional<std::string> error;
2575 std::string raw;
2576 if (storage_.load_delegation_with_messages == nullptr) {
2577 error = "storage unavailable";
2578 } else if (!storage_.load_delegation_with_messages(
2579 id.c_str(), raw, storage_.user_data)) {
2580 error = "unknown delegation_id";
2581 } else {
2582 parsed = nlohmann::json::parse(raw, nullptr, false);
2583 if (parsed.is_discarded() || !parsed.is_object()) {
2584 error = "malformed storage payload";
2585 }
2586 }
2587 if (error.has_value()) {
2588 logger->error("resume_delegation '{}': {}", id, *error);
2589 push_resume_failure(ctx, *error, id);
2590 return false;
2591 }
2592 return true;
2593}
2594
2600bool AgentEngine::resolve_resume_delegation(
2601 LoopContext& ctx,
2602 PendingDelegation& pending,
2603 std::vector<Message>& out_history) {
2604 const auto& id = pending.resume_from_delegation_id;
2605 nlohmann::json j;
2606 if (!fetch_resume_payload(ctx, id, j)) {
2607 return false;
2608 }
2609 auto target = j.value("target_tier", std::string{});
2610 if (target.empty()) {
2611 push_resume_failure(ctx, "target_tier missing in storage", id);
2612 return false;
2613 }
2614 pending.target = target;
2615 if (j.contains("messages") && j["messages"].is_array()) {
2616 for (const auto& mj : j["messages"]) {
2617 Message m;
2618 m.role = mj.value("role", "");
2619 m.content = mj.value("content", "");
2620 if (!m.role.empty()) {
2621 out_history.push_back(std::move(m));
2622 }
2623 }
2624 }
2625 logger->info("resume_delegation '{}': loaded {} messages, target='{}'",
2626 id, out_history.size(), target);
2627 return true;
2628}
2629
2644DelegationResult AgentEngine::run_pending_delegation(
2645 LoopContext& ctx,
2646 const PendingDelegation& pending,
2647 std::vector<Message> resume_history) {
2648 std::optional<int> max_turns;
2649 if (pending.max_turns > 0) { max_turns = pending.max_turns; }
2650 DelegationManager mgr(run_child_loop_trampoline, this,
2651 tier_res_, get_repo_dir(),
2652 ensure_sandbox_manager());
2653 if (storage_.create_delegation != nullptr) {
2654 mgr.set_storage(&storage_);
2655 }
2656 auto cb_snap = delegation_callbacks_snapshot();
2657 mgr.set_delegation_callbacks(
2658 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2659 if (resume_history.empty()) {
2660 return mgr.execute_delegation(
2661 ctx, pending.target, pending.task, max_turns);
2662 }
2663 return mgr.execute_resume_delegation(
2664 ctx, pending.target, pending.task,
2665 std::move(resume_history), max_turns);
2666}
2667
2673SandboxManager* AgentEngine::ensure_sandbox_manager() {
2674 if (sandbox_mgr_) {
2675 return &*sandbox_mgr_;
2676 }
2677 auto repo_dir = get_repo_dir();
2678 if (repo_dir.empty()) {
2679 return nullptr;
2680 }
2681 sandbox_mgr_.emplace(repo_dir);
2682 return &*sandbox_mgr_;
2683}
2684
2685// ── Conversation state (v2.0.2) ─────────────────────────────
2686
2693void AgentEngine::set_system_prompt(const std::string& prompt) {
2694 system_prompt_ = prompt;
2695}
2696
2703void AgentEngine::set_session_logger(SessionLogger* log) {
2704 session_logger_ = log;
2705}
2706
2720std::vector<Message> AgentEngine::run_turn(const std::string& input) {
2721 // gh#40 (v2.1.10): the drain loop turns mid-generation queued user
2722 // messages into subsequent turns at this single top-level COMPLETE
2723 // boundary. running_flag_ lets the facade reject
2724 // entropic_queue_user_message with INVALID_STATE when idle.
2725 running_flag_.store(true);
2726 if (conversation_.empty() && !system_prompt_.empty()) {
2727 Message sys;
2728 sys.role = "system";
2729 sys.content = system_prompt_;
2730 conversation_.push_back(std::move(sys));
2731 }
2732 auto result = run_drain_loop(input, /*tier_override=*/"");
2733 running_flag_.store(false);
2734 return result;
2735}
2736
2754std::vector<Message> AgentEngine::run_turn_as(const std::string& tier,
2755 const std::string& input) {
2756 running_flag_.store(true);
2757 seed_system_prompt_for_tier(tier);
2758 auto result = run_drain_loop(input, tier);
2759 running_flag_.store(false);
2760 return result;
2761}
2762
2775void AgentEngine::seed_system_prompt_for_tier(const std::string& tier) {
2776 if (!conversation_.empty()) { return; }
2777 auto it = tier_info_.find(tier);
2778 const std::string& sp =
2779 (it != tier_info_.end() && !it->second.system_prompt.empty())
2780 ? it->second.system_prompt
2781 : system_prompt_;
2782 if (sp.empty()) { return; }
2783 Message sys;
2784 sys.role = "system";
2785 sys.content = sp;
2786 conversation_.push_back(std::move(sys));
2787}
2788
2797std::vector<Message> AgentEngine::run_drain_loop(
2798 std::string pending, const std::string& tier_override) {
2799 std::vector<Message> result;
2800 while (true) {
2801 Message usr;
2802 usr.role = "user";
2803 usr.content = pending;
2804 conversation_.push_back(std::move(usr));
2805
2806 size_t sent_len = conversation_.size();
2807 result = run(conversation_, tier_override); // copy — run() may mutate
2808 for (size_t i = sent_len; i < result.size(); ++i) {
2809 conversation_.push_back(result[i]);
2810 }
2811 auto next = pop_queued_user_message();
2812 if (!next.has_value()) { break; }
2813 fire_queue_consumed(*next, user_message_queue_depth());
2814 pending = std::move(*next);
2815 }
2816 return result;
2817}
2818
2841void AgentEngine::seed_system_prompt(
2842 const std::vector<Message>& new_messages) {
2843 bool caller_has_system = false;
2844 for (const auto& m : new_messages) {
2845 if (m.role == "system") { caller_has_system = true; break; }
2846 }
2847 if (conversation_.empty() && !system_prompt_.empty()
2848 && !caller_has_system) {
2849 Message sys;
2850 sys.role = "system";
2851 sys.content = system_prompt_;
2852 conversation_.push_back(std::move(sys));
2853 }
2854}
2855
2863bool AgentEngine::prepare_next_turn(std::vector<Message>& pending) {
2864 auto next = pop_queued_user_message();
2865 if (!next.has_value()) { return false; }
2866 fire_queue_consumed(*next, user_message_queue_depth());
2867 Message usr;
2868 usr.role = "user";
2869 usr.content = std::move(*next);
2870 pending.clear();
2871 pending.push_back(std::move(usr));
2872 return true;
2873}
2874
2882std::vector<Message> AgentEngine::run_turn(std::vector<Message> new_messages) {
2883 // gh#40 (v2.1.10): mirror the single-string overload's drain
2884 // loop. Queued messages enqueued via entropic_queue_user_message
2885 // become subsequent plain-text user turns at this top-level
2886 // boundary (no content_parts — the queue ABI is text-only).
2887 running_flag_.store(true);
2888 seed_system_prompt(new_messages);
2889 std::vector<Message> pending = std::move(new_messages);
2890 std::vector<Message> result;
2891 while (true) {
2892 for (auto& m : pending) {
2893 conversation_.push_back(std::move(m));
2894 }
2895 size_t sent_len = conversation_.size();
2896 result = run(conversation_);
2897 for (size_t i = sent_len; i < result.size(); ++i) {
2898 conversation_.push_back(result[i]);
2899 }
2900 if (!prepare_next_turn(pending)) { break; }
2901 }
2902 running_flag_.store(false);
2903 return result;
2904}
2905
2916int AgentEngine::run_streaming(
2917 const std::string& input,
2918 TokenCallback on_token,
2919 void* user_data,
2920 int* cancel_flag)
2921{
2922 if (session_logger_) {
2923 session_logger_->log_user_input(input);
2924 }
2925
2926 StreamThinkFilter filter(on_token, user_data);
2927 if (session_logger_ && session_logger_->is_open()) {
2928 filter.set_raw_callback(
2929 SessionLogger::raw_token_callback,
2930 session_logger_);
2931 }
2932
2933 struct Ctx {
2934 StreamThinkFilter* filter;
2935 int* cancel;
2936 AgentEngine* engine;
2937 };
2938 Ctx sctx{&filter, cancel_flag, this};
2939
2940 EngineCallbacks cbs{};
2941 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
2942 auto* c = static_cast<Ctx*>(ud);
2943 if (c->cancel && *c->cancel) {
2944 c->engine->interrupt();
2945 return;
2946 }
2947 c->filter->on_token(t, l);
2948 };
2949 cbs.user_data = &sctx;
2950 set_callbacks(cbs);
2951
2952 auto result = run_turn(input);
2953 filter.flush();
2954
2955 if (session_logger_) {
2956 session_logger_->end_turn();
2957 }
2958 if (cancel_flag && *cancel_flag) { return 1; }
2959 return 0;
2960}
2961
2969static std::string concat_user_echo(
2970 const std::vector<Message>& messages) {
2971 std::string echo;
2972 for (const auto& m : messages) {
2973 if (m.role != "user" || m.content.empty()) { continue; }
2974 if (!echo.empty()) { echo += '\n'; }
2975 echo += m.content;
2976 }
2977 return echo;
2978}
2979
2995int AgentEngine::run_streaming(
2996 std::vector<Message> new_messages,
2997 TokenCallback on_token,
2998 void* user_data,
2999 int* cancel_flag)
3000{
3001 if (session_logger_) {
3002 session_logger_->log_user_input(concat_user_echo(new_messages));
3003 }
3004
3005 StreamThinkFilter filter(on_token, user_data);
3006 if (session_logger_ && session_logger_->is_open()) {
3007 filter.set_raw_callback(
3008 SessionLogger::raw_token_callback,
3009 session_logger_);
3010 }
3011
3012 struct Ctx {
3013 StreamThinkFilter* filter;
3014 int* cancel;
3015 AgentEngine* engine;
3016 };
3017 Ctx sctx{&filter, cancel_flag, this};
3018
3019 EngineCallbacks cbs{};
3020 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
3021 auto* c = static_cast<Ctx*>(ud);
3022 if (c->cancel && *c->cancel) {
3023 c->engine->interrupt();
3024 return;
3025 }
3026 c->filter->on_token(t, l);
3027 };
3028 cbs.user_data = &sctx;
3029 set_callbacks(cbs);
3030
3031 auto result = run_turn(std::move(new_messages));
3032 filter.flush();
3033
3034 if (session_logger_) {
3035 session_logger_->end_turn();
3036 }
3037 if (cancel_flag && *cancel_flag) { return 1; }
3038 return 0;
3039}
3040
3046void AgentEngine::clear_conversation() {
3047 conversation_.clear();
3048 logger->info("conversation cleared");
3049}
3050
3057size_t AgentEngine::message_count() const {
3058 return conversation_.size();
3059}
3060
3067const std::vector<Message>& AgentEngine::get_messages() const {
3068 return conversation_;
3069}
3070
3071// ── Mid-generation user-message queue (gh#40, v2.1.10) ─────────
3072
3078bool AgentEngine::queue_user_message(const std::string& message) {
3079 std::lock_guard lock(queue_mutex_);
3080 int cap = loop_config_.message_queue_capacity;
3081 if (cap < 0) { cap = 0; }
3082 if (user_message_queue_.size()
3083 >= static_cast<size_t>(cap)) {
3084 return false;
3085 }
3086 user_message_queue_.push_back(message);
3087 logger->info("queued mid-gen user message: depth={}",
3088 user_message_queue_.size());
3089 return true;
3090}
3091
3097size_t AgentEngine::user_message_queue_depth() const {
3098 std::lock_guard lock(queue_mutex_);
3099 return user_message_queue_.size();
3100}
3101
3107void AgentEngine::clear_user_message_queue() {
3108 std::lock_guard lock(queue_mutex_);
3109 size_t dropped = user_message_queue_.size();
3110 user_message_queue_.clear();
3111 if (dropped > 0) {
3112 logger->info("cleared mid-gen queue: dropped={}", dropped);
3113 }
3114}
3115
3121void AgentEngine::set_message_queue_capacity(int cap) {
3122 std::lock_guard lock(queue_mutex_);
3123 loop_config_.message_queue_capacity = cap < 0 ? 0 : cap;
3124}
3125
3131std::optional<std::string>
3132AgentEngine::pop_queued_user_message() {
3133 std::lock_guard lock(queue_mutex_);
3134 if (user_message_queue_.empty()) {
3135 return std::nullopt;
3136 }
3137 std::string front = std::move(user_message_queue_.front());
3138 user_message_queue_.pop_front();
3139 return front;
3140}
3141
3147void AgentEngine::fire_queue_consumed(const std::string& consumed,
3148 size_t remaining) {
3149 if (queue_observer_ != nullptr) {
3150 queue_observer_(
3151 consumed.c_str(), remaining, queue_observer_data_);
3152 }
3153}
3154
3160void AgentEngine::set_queue_observer(
3161 void (*observer)(const char*, size_t, void*),
3162 void* user_data) {
3163 queue_observer_ = observer;
3164 queue_observer_data_ = user_data;
3165}
3166
3178void AgentEngine::set_state_observer(
3179 void (*observer)(int, void*),
3180 void* user_data) {
3181 state_observer_ = observer;
3182 state_observer_data_ = user_data;
3183 response_generator_.set_state_observer(observer, user_data);
3184}
3185
3186// ── Directive hooks (v2.0.2) ────────────────────────────────
3187
3194ToolExecutorHooks AgentEngine::build_directive_hooks() {
3195 ToolExecutorHooks hooks;
3196 hooks.process_directives = [](
3197 LoopContext& ctx,
3198 const std::vector<const Directive*>& dirs,
3199 void* ud) -> DirectiveResult {
3200 return static_cast<AgentEngine*>(ud)
3201 ->directive_processor().process(ctx, dirs);
3202 };
3203 hooks.user_data = this;
3204 return hooks;
3205}
3206
3207// ── Tier resolution (v2.0.2) ────────────────────────────────
3208
3216void AgentEngine::set_tier_info(
3217 const std::string& name,
3218 const ChildContextInfo& info)
3219{
3220 tier_info_[name] = info;
3221}
3222
3230bool AgentEngine::has_tier(const std::string& name) const {
3231 return tier_info_.count(name) > 0;
3232}
3233
3241const std::string& AgentEngine::tier_system_prompt(
3242 const std::string& name) const {
3243 static const std::string kEmpty;
3244 auto it = tier_info_.find(name);
3245 return it == tier_info_.end() ? kEmpty : it->second.system_prompt;
3246}
3247
3254void AgentEngine::set_relay_single_delegate(const std::string& name) {
3255 relay_single_delegate_tiers_.insert(name);
3256}
3257
3264void AgentEngine::set_handoff_rules(
3265 const std::unordered_map<std::string,
3266 std::vector<std::string>>& rules)
3267{
3268 handoff_rules_ = rules;
3269 wire_internal_tier_resolution();
3270}
3271
3277ChildContextInfo AgentEngine::tri_resolve_tier(
3278 const std::string& name, void* ud) {
3279 auto* self = static_cast<AgentEngine*>(ud);
3280 auto it = self->tier_info_.find(name);
3281 ChildContextInfo info;
3282 if (it == self->tier_info_.end()) {
3283 info.valid = false;
3284 } else {
3285 info = it->second;
3286 }
3287 return info;
3288}
3289
3298bool AgentEngine::tri_tier_exists(const std::string& name, void* ud) {
3299 auto* self = static_cast<AgentEngine*>(ud);
3300 return self->tier_info_.count(name) > 0;
3301}
3302
3311std::vector<std::string> AgentEngine::tri_get_handoff_targets(
3312 const std::string& name, void* ud) {
3313 auto* self = static_cast<AgentEngine*>(ud);
3314 auto it = self->handoff_rules_.find(name);
3315 std::vector<std::string> result;
3316 if (it != self->handoff_rules_.end()) { result = it->second; }
3317 return result;
3318}
3319
3343std::string AgentEngine::tri_get_tier_param(const std::string& name,
3344 const std::string& param, void* ud) {
3345 auto* self = static_cast<AgentEngine*>(ud);
3346 auto it = self->tier_info_.find(name);
3347 if (it == self->tier_info_.end()) { return ""; }
3348 const auto& info = it->second;
3349 std::string result;
3350 if (param == "explicit_completion") {
3351 result = info.explicit_completion ? "true" : "false";
3352 } else if (param == "max_iterations"
3353 && info.max_iterations_override >= 0) {
3354 result = std::to_string(info.max_iterations_override);
3355 } else if (param == "max_tool_calls_per_turn"
3356 && info.max_tool_calls_per_turn_override >= 0) {
3357 result = std::to_string(info.max_tool_calls_per_turn_override);
3358 }
3359 return result;
3360}
3361
3367void AgentEngine::wire_internal_tier_resolution() {
3368 TierResolutionInterface tri;
3369 tri.resolve_tier = &AgentEngine::tri_resolve_tier;
3370 tri.tier_exists = &AgentEngine::tri_tier_exists;
3371 tri.get_handoff_targets = &AgentEngine::tri_get_handoff_targets;
3372 tri.get_tier_param = &AgentEngine::tri_get_tier_param;
3373 tri.user_data = this;
3374 set_tier_resolution(tri);
3375}
3376
3377} // 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:343
AgentEngine(const InferenceInterface &inference, const LoopConfig &loop_config, const CompactionConfig &compaction_config)
Construct an agent engine.
Definition engine.cpp:133
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.
DelegationManager — child loop creation and execution.
Core agent execution engine.
ent_decision_t
Consumer decision returned from delegation callbacks.
Definition entropic.h:997
entropic_directive_type_t
Directive types emitted by MCP tool results.
Definition enums.h:53
@ ENTROPIC_DIRECTIVE_TIER_CHANGE
Switch active tier.
Definition enums.h:55
@ ENTROPIC_DIRECTIVE_STOP_PROCESSING
Halt directive processing.
Definition enums.h:54
@ ENTROPIC_DIRECTIVE_PRUNE_MESSAGES
Prune old tool results.
Definition enums.h:61
@ ENTROPIC_DIRECTIVE_INJECT_CONTEXT
Inject message into context.
Definition enums.h:60
@ ENTROPIC_DIRECTIVE_COMPLETE
Mark task complete.
Definition enums.h:58
@ ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER
Generic UI notification passthrough.
Definition enums.h:64
@ ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS
Clear self-directed todos (engine no-op)
Definition enums.h:59
@ ENTROPIC_DIRECTIVE_PHASE_CHANGE
Switch active inference phase.
Definition enums.h:63
@ ENTROPIC_DIRECTIVE_PIPELINE
Multi-stage sequential execution.
Definition enums.h:57
@ ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR
Replace context anchor.
Definition enums.h:62
@ ENTROPIC_DIRECTIVE_DELEGATE
Route to another identity.
Definition enums.h:56
entropic_hook_point_t
Hook points in the engine lifecycle.
Definition hooks.h:34
@ ENTROPIC_HOOK_ON_LOOP_START
20: Agentic loop entry
Definition hooks.h:58
@ ENTROPIC_HOOK_ON_DELEGATE
8: Delegation to child tier started
Definition hooks.h:44
@ ENTROPIC_HOOK_ON_LOOP_END
21: Agentic loop exit
Definition hooks.h:59
@ ENTROPIC_HOOK_ON_STATE_CHANGE
6: Engine state machine transition
Definition hooks.h:42
@ ENTROPIC_HOOK_PRE_GENERATE
0: Before inference generate call
Definition hooks.h:36
@ ENTROPIC_HOOK_ON_CONTEXT_ASSEMBLE
10: Context window assembled
Definition hooks.h:46
@ ENTROPIC_HOOK_ON_LOOP_ITERATION
5: Each agentic loop iteration
Definition hooks.h:41
@ ENTROPIC_HOOK_ON_ERROR
7: Async error occurred
Definition hooks.h:43
@ ENTROPIC_HOOK_POST_GENERATE
1: After inference generate returns
Definition hooks.h:37
@ ENTROPIC_HOOK_ON_DELEGATE_COMPLETE
9: Child delegation completed
Definition hooks.h:45
@ ENTROPIC_HOOK_ON_COMPLETE
entropic.complete MCP tool called — pre-hook, can cancel.
Definition hooks.h:87
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:2192
static void fire_context_assemble_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_CONTEXT_ASSEMBLE info hook.
Definition engine.cpp:99
static std::string extract_system_prompt(const std::vector< Message > &messages)
Extract the first system message content from messages.
Definition engine.cpp:1685
static double now_seconds()
Get current time as seconds since epoch.
Definition engine.cpp:117
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:1304
ToolResultKind
Categorical outcome of a single tool invocation.
Definition tool_result.h:31
@ 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:1819
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:1327
static std::string json_escape_engine(const std::string &s)
JSON-escape a string (no surrounding quotes).
Definition engine.cpp:1518
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:1625
static void remove_anchor_messages(LoopContext &ctx, const std::string &key)
Remove messages with a specific anchor key.
Definition engine.cpp:1452
const char * agent_state_name(AgentState state)
Get the string name for an AgentState value.
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:2046
static void fire_loop_start_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_START info hook.
Definition engine.cpp:50
static std::string build_tool_manifest(const std::vector< Message > &messages)
Build tool call manifest from conversation messages.
Definition engine.cpp:1541
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:1573
static const std::array< const char *, 256 > & json_escape_table()
256-entry table mapping bytes to JSON escape sequences.
Definition engine.cpp:1498
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:1667
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:2026
static void run_child_loop_trampoline(LoopContext &ctx, void *user_data)
Trampoline for DelegationManager to call engine loop.
Definition engine.cpp:1966
const char * result_kind_to_string(ToolResultKind kind)
Serialize a ToolResultKind to its wire-stable string form.
Definition tool_result.h:50
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:35
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:1979
static std::string concat_user_echo(const std::vector< Message > &messages)
Concatenate user-role message text for session-log echo.
Definition engine.cpp:2969
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:82
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:2546
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:1999
static void fire_loop_end_hook(const HookInterface &hooks, const LoopContext &ctx)
Build + fire the ON_LOOP_END info hook.
Definition engine.cpp:66
Filesystem-based sandbox isolation for delegations.
Request describing a delegation that is about to run.
Definition entropic.h:1011
Result of a finalized delegation, delivered to the consumer.
Definition entropic.h:1034
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:697
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:35
std::unordered_map< std::string, std::string > metadata
Arbitrary metadata.
Definition message.h:39
std::string content
Message text content (always populated)
Definition message.h:37
std::string role
Message role.
Definition message.h:36
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.