Entropic 2.3.8
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
342void AgentEngine::run_loop(LoopContext& ctx) {
343 reset_interrupt();
344 pause_flag_.store(false);
345 apply_identity_overrides(ctx);
346 reinject_context_anchors(ctx);
347 if (ctx.metrics.start_time == 0.0) {
349 }
350 set_state(ctx, AgentState::PLANNING);
351 loop(ctx);
353 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
354 auto& tm = per_tier_metrics_[
355 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
356 tm.iterations += ctx.metrics.iterations;
357 tm.tool_calls += ctx.metrics.tool_calls;
358 tm.tokens_used += ctx.metrics.tokens_used;
359 tm.errors += ctx.metrics.errors;
360 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
361}
362
370std::vector<Message> AgentEngine::run(std::vector<Message> messages) {
371 // gh#35: update activity timestamp at run() entry so any host
372 // polling `seconds_since_last_activity` for an idle-exit policy
373 // sees a fresh value as soon as work begins.
374 last_activity_epoch_s_.store(
375 std::chrono::duration_cast<std::chrono::seconds>(
376 std::chrono::system_clock::now().time_since_epoch()).count());
377
378 LoopContext ctx;
379 ctx.messages = std::move(messages);
381
382 init_session_conversation(ctx);
383
384 reset_interrupt();
385 pause_flag_.store(false);
386
387 reinject_context_anchors(ctx);
388 set_state(ctx, AgentState::PLANNING);
389
390 loop(ctx);
391
393 accumulate_run_metrics(ctx);
394 logger->info("Loop complete: {} iterations, {}ms",
396 return ctx.messages;
397}
398
405void AgentEngine::init_session_conversation(LoopContext& ctx) {
406 // gh#48 (v2.1.12): create the root conversation row when storage
407 // is wired so `ctx.conversation_id` carries a valid FK into
408 // `conversations(id)`. Every downstream delegation copies
409 // `parent_ctx.conversation_id` into `child_ctx.parent_conversation_id`
410 // (`delegation.cpp:387`); without this, the empty string propagates
411 // and every `INSERT INTO delegations` violates the FK silently,
412 // making `entropic.followup` return empty across an entire session.
413 if (storage_.create_conversation == nullptr) { return; }
414 std::string conv_id;
415 if (storage_.create_conversation("session", conv_id, storage_.user_data)
416 && !conv_id.empty()) {
417 ctx.conversation_id = std::move(conv_id);
418 } else {
419 logger->warn("Storage create_conversation failed at run() init; "
420 "delegations will not persist this session (gh#48)");
421 }
422}
423
430void AgentEngine::accumulate_run_metrics(LoopContext& ctx) {
431 last_metrics_ = ctx.metrics; // P2-15: snapshot for entropic_status
432 // Per-tier accumulator (P2-15 follow-up, 2.0.6-rc16.2)
433 auto& tm = per_tier_metrics_[
434 ctx.locked_tier.empty() ? "lead" : ctx.locked_tier];
435 tm.iterations += ctx.metrics.iterations;
436 tm.tool_calls += ctx.metrics.tool_calls;
437 tm.tokens_used += ctx.metrics.tokens_used;
438 tm.errors += ctx.metrics.errors;
439 tm.end_time += (ctx.metrics.end_time - ctx.metrics.start_time);
440}
441
448void AgentEngine::loop(LoopContext& ctx) {
449 fire_loop_start_hook(hooks_, ctx); // ON_LOOP_START (v1.9.1)
450
451 while (!should_stop(ctx)) {
452 ctx.metrics.iterations++;
453
454 if (interrupt_flag_.load()) {
455 set_state(ctx, AgentState::INTERRUPTED);
456 break;
457 }
458
459 execute_iteration(ctx);
460
461 if (ctx.state == AgentState::ERROR) {
462 break;
463 }
464 }
465
466 if (ctx.metrics.iterations >= resolve_max_iterations(ctx)
467 && ctx.state != AgentState::COMPLETE
468 && ctx.state != AgentState::ERROR
469 && ctx.state != AgentState::INTERRUPTED) {
470 // P3-18 follow-up (2.0.6-rc16.2): force synthetic completion
471 // so the facade returns a proper result when the cap is hit.
472 // E7 (2.0.6-rc18): mark ctx.metadata with the terminal reason
473 // so delegate result and parent-tier relay can surface that
474 // the child did not complete naturally.
475 logger->warn("Loop ended due to max iterations ({}/{}) — "
476 "forcing synthetic entropic.complete",
477 ctx.metrics.iterations, resolve_max_iterations(ctx));
478 Message forced;
479 forced.role = "assistant";
480 forced.content = "[iteration cap reached after "
481 + std::to_string(ctx.metrics.iterations)
482 + " iterations — returning current state]";
483 ctx.messages.push_back(std::move(forced));
484 ctx.metadata["terminal_reason"] = "budget_exhausted";
485 set_state(ctx, AgentState::COMPLETE);
486 }
487
488 fire_loop_end_hook(hooks_, ctx); // ON_LOOP_END (v1.9.1)
489}
490
497void AgentEngine::execute_iteration(LoopContext& ctx) {
498 logger->info("[LOOP] iter {}/{} state={} msgs={}",
499 ctx.metrics.iterations,
500 resolve_max_iterations(ctx),
501 agent_state_name(ctx.state),
502 ctx.messages.size());
503
504 fire_loop_iteration_hook(hooks_, ctx); // ON_LOOP_ITERATION (v1.9.1)
505
506 context_manager_.refresh_context_limit(ctx, 0);
507 context_manager_.prune_old_tool_results(ctx);
508 context_manager_.check_compaction(ctx);
509
510 fire_context_assemble_hook(hooks_, ctx); // ON_CONTEXT_ASSEMBLE (v1.9.1)
511
512 set_state(ctx, AgentState::EXECUTING);
513
514 // Hook: PRE_GENERATE — can cancel (v1.9.1)
515 if (fire_pre_hook(ENTROPIC_HOOK_PRE_GENERATE, ctx.metrics.iterations)) {
516 set_state(ctx, AgentState::COMPLETE);
517 return;
518 }
519
520 auto result = response_generator_.generate_response(ctx);
521 dispatch_post_generate(ctx, result);
522 process_generation_result(ctx, result);
523}
524
532void AgentEngine::process_generation_result(LoopContext& ctx,
533 GenerateResult& result) {
534 auto [cleaned, tool_calls] = parse_tool_calls(result.content);
535 logger->info("[ITER] finish={}, {} tool call(s), {} chars",
536 result.finish_reason, tool_calls.size(),
537 cleaned.size());
538 Message assistant_msg{"assistant", cleaned};
539 ctx.messages.push_back(std::move(assistant_msg));
540
541 if (!tool_calls.empty() && tool_exec_.process_tool_calls != nullptr) {
542 process_tool_results(ctx, tool_calls);
543 } else {
544 evaluate_no_tool_decision(ctx, cleaned, result.finish_reason);
545 }
546
547 // Execute pending delegation/pipeline (v1.8.6)
548 if (ctx.pending_delegation.has_value()) {
549 ctx.metrics.iterations--;
550 execute_pending_delegation(ctx);
551 } else if (ctx.pending_pipeline.has_value()) {
552 ctx.metrics.iterations--;
553 execute_pending_pipeline(ctx);
554 }
555}
556
565void AgentEngine::evaluate_no_tool_decision(
566 LoopContext& ctx,
567 const std::string& content,
568 const std::string& finish_reason) {
569 if (handle_terminal_finish_reasons(ctx, finish_reason)) { return; }
570 if (try_auto_chain(ctx, finish_reason, content)) {
571 logger->info("[DECISION] auto-chain triggered");
572 return;
573 }
574 if (record_explicit_completion_failure(ctx, finish_reason)) { return; }
575 if (response_generator_.is_response_complete(content, "[]")) {
576 logger->info("[DECISION] response complete");
577 set_state(ctx, AgentState::COMPLETE);
578 }
579}
580
594bool AgentEngine::handle_terminal_finish_reasons(
595 LoopContext& ctx, const std::string& finish_reason) {
596 if (finish_reason == "interrupted") {
597 logger->info("[DECISION] interrupted");
598 set_state(ctx, AgentState::INTERRUPTED);
599 return true;
600 }
601 if (finish_reason == "length") {
602 logger->info("[DECISION] length, continuing");
603 return true;
604 }
605 return false;
606}
607
618bool AgentEngine::record_explicit_completion_failure(
619 LoopContext& ctx, const std::string& finish_reason) {
620 if (!tier_requires_explicit_completion(ctx.locked_tier)) {
621 return false;
622 }
623 auto& retries = ctx.metadata["zero_tool_call_retries"];
624 int n = retries.empty() ? 0 : std::atoi(retries.c_str());
625 logger->warn("[DECISION] tier '{}' requires explicit completion "
626 "but iteration emitted zero tool calls "
627 "(finish_reason={}) retry={}/2",
628 ctx.locked_tier, finish_reason, n);
629 ctx.metadata["failure_reason"] =
630 "zero_tool_calls_with_explicit_completion";
631 ctx.metadata["failure_tier"] = ctx.locked_tier;
632 if (n >= 2) {
633 logger->error("[DECISION] zero-tool-call retries exhausted "
634 "(tier={}), failing turn", ctx.locked_tier);
635 set_state(ctx, AgentState::ERROR);
636 return true;
637 }
638 Message correction;
639 correction.role = "user";
640 correction.content =
641 "[SYSTEM] Your previous response contained no tool call. "
642 "You must end every turn with exactly one tool call "
643 "(entropic.complete, entropic.delegate, or entropic.inspect). "
644 "Retry.";
645 ctx.messages.push_back(std::move(correction));
646 retries = std::to_string(n + 1);
647 set_state(ctx, AgentState::EXECUTING);
648 return true;
649}
650
663bool AgentEngine::tier_requires_explicit_completion(
664 const std::string& tier) const {
665 if (tier_res_.get_tier_param == nullptr) { return false; }
666 auto val = tier_res_.get_tier_param(
667 tier, "explicit_completion", tier_res_.user_data);
668 return val == "true" || val == "1";
669}
670
684bool AgentEngine::is_delegation_cycle(
685 const LoopContext& ctx, const std::string& target) const {
686 if (target == ctx.locked_tier) { return true; }
687 for (const auto& anc : ctx.delegation_ancestor_tiers) {
688 if (anc == target) { return true; }
689 }
690 return false;
691}
692
698bool AgentEngine::is_delegation_repeat_blocked(
699 const LoopContext& ctx, const std::string& target) const {
700 return target == ctx.last_failed_delegation_target
702 >= loop_config_.max_consecutive_failed_delegations;
703}
704
710bool AgentEngine::fold_complete_into_assistant(
711 LoopContext& ctx, const Message& tool_result_msg) const {
712 auto tn_it = tool_result_msg.metadata.find("tool_name");
713 bool is_complete = tn_it != tool_result_msg.metadata.end()
714 && tn_it->second == "entropic.complete";
715 if (!is_complete || ctx.messages.empty()) { return false; }
716 auto& last = ctx.messages.back();
717 auto sum_it = ctx.metadata.find("explicit_completion_summary");
718 bool foldable = last.role == "assistant" && last.content.empty()
719 && sum_it != ctx.metadata.end();
720 if (!foldable) { return false; }
721 last.content = sum_it->second;
722 return true;
723}
724
730int64_t AgentEngine::seconds_since_last_activity() const {
731 auto last = last_activity_epoch_s_.load();
732 if (last == 0) { return 0; }
733 auto now = std::chrono::duration_cast<std::chrono::seconds>(
734 std::chrono::system_clock::now().time_since_epoch()).count();
735 return now > last ? (now - last) : 0;
736}
737
745bool AgentEngine::should_stop(const LoopContext& ctx) const {
746 bool terminal = ctx.state == AgentState::COMPLETE
747 || ctx.state == AgentState::ERROR
748 || ctx.state == AgentState::INTERRUPTED;
749 bool at_limit = ctx.metrics.iterations >= resolve_max_iterations(ctx)
751 return terminal || at_limit;
752}
753
761void AgentEngine::set_state(LoopContext& ctx, AgentState state) {
762 auto prev = ctx.state;
763 ctx.state = state;
764 logger->info("State: {}", agent_state_name(state));
765 if (callbacks_.on_state_change != nullptr) {
766 callbacks_.on_state_change(
767 static_cast<int>(state), callbacks_.user_data);
768 }
769 // Persistent slot (gh#40 fallout, v2.1.10): the streaming entry
770 // points overwrite callbacks_ via set_callbacks(), so the legacy
771 // on_state_change is silent for streaming runs. The persistent
772 // slot is unaffected by that shuffle.
773 if (state_observer_ != nullptr) {
774 state_observer_(static_cast<int>(state), state_observer_data_);
775 }
776
777 // Hook: ON_STATE_CHANGE (v1.9.1)
778 {
779 std::string json = "{\"previous\":\""
780 + std::string(agent_state_name(prev))
781 + "\",\"current\":\""
782 + std::string(agent_state_name(state)) + "\"}";
783 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_STATE_CHANGE, json.c_str());
784 }
785
786 // Hook: ON_ERROR when entering ERROR state (v1.9.1)
787 if (state == AgentState::ERROR) {
788 std::string json = "{\"error_code\":\"STATE_ERROR\""
789 ",\"iteration\":"
790 + std::to_string(ctx.metrics.iterations)
791 + ",\"consecutive\":" + std::to_string(ctx.consecutive_errors)
792 + "}";
793 fire_hook_info(hooks_, ENTROPIC_HOOK_ON_ERROR, json.c_str());
794 }
795}
796
808void AgentEngine::interrupt() {
809 if (!interrupt_flag_.exchange(true)) {
810 logger->info("Engine interrupted");
811 // P1-10: propagate to external MCP transports so in-flight
812 // tool calls abort alongside the generation loop.
813 if (external_interrupt_cb_ != nullptr) {
814 external_interrupt_cb_(external_interrupt_data_);
815 }
816 }
817 pause_flag_.store(false);
818}
819
832void AgentEngine::set_external_interrupt(void (*cb)(void*),
833 void* user_data) {
834 external_interrupt_cb_ = cb;
835 external_interrupt_data_ = user_data;
836}
837
843void AgentEngine::reset_interrupt() {
844 interrupt_flag_.store(false);
845}
846
852void AgentEngine::pause() {
853 logger->info("Engine paused");
854 pause_flag_.store(true);
855}
856
862void AgentEngine::cancel_pause() {
863 logger->info("Pause cancelled, interrupting");
864 pause_flag_.store(false);
865 interrupt_flag_.store(true);
866}
867
875std::pair<int, int> AgentEngine::context_usage(
876 const std::vector<Message>& messages) const {
877 int used = token_counter_.count_messages(messages);
878 return {used, token_counter_.max_tokens};
879}
880
887void AgentEngine::reinject_context_anchors(LoopContext& ctx) {
888 for (const auto& [key, content] : context_anchors_) {
889 ContextAnchorDirective d(key, content);
891 dir_anchor(ctx, d, r);
892 }
893}
894
895// ── Directive handler registration ───────────────────────
896
902void AgentEngine::register_directive_handlers() {
903 auto reg = [this](entropic_directive_type_t t, auto fn) {
904 directive_processor_.register_handler(t,
905 [this, fn](LoopContext& c, const Directive& d,
906 DirectiveResult& r) {
907 (this->*fn)(c, d, r);
908 });
909 };
910 reg(ENTROPIC_DIRECTIVE_STOP_PROCESSING, &AgentEngine::dir_stop);
911 reg(ENTROPIC_DIRECTIVE_TIER_CHANGE, &AgentEngine::dir_tier_change);
912 reg(ENTROPIC_DIRECTIVE_DELEGATE, &AgentEngine::dir_delegate);
913 reg(ENTROPIC_DIRECTIVE_PIPELINE, &AgentEngine::dir_pipeline);
914 reg(ENTROPIC_DIRECTIVE_COMPLETE, &AgentEngine::dir_complete);
915 reg(ENTROPIC_DIRECTIVE_CLEAR_SELF_TODOS,&AgentEngine::dir_clear_todos);
916 reg(ENTROPIC_DIRECTIVE_INJECT_CONTEXT, &AgentEngine::dir_inject);
917 reg(ENTROPIC_DIRECTIVE_PRUNE_MESSAGES, &AgentEngine::dir_prune);
918 reg(ENTROPIC_DIRECTIVE_CONTEXT_ANCHOR, &AgentEngine::dir_anchor);
919 reg(ENTROPIC_DIRECTIVE_PHASE_CHANGE, &AgentEngine::dir_phase);
920 reg(ENTROPIC_DIRECTIVE_NOTIFY_PRESENTER,&AgentEngine::dir_notify);
921}
922
923// ── Directive handlers ───────────────────────────────────
924
930void AgentEngine::dir_stop(
931 LoopContext&, const Directive&, DirectiveResult& r) {
932 logger->info("[DIRECTIVE] stop_processing");
933 r.stop_processing = true;
934}
935
941void AgentEngine::dir_tier_change(
942 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
943 const auto& tc = static_cast<const TierChangeDirective&>(d);
944 ctx.locked_tier = tc.tier;
945 r.tier_changed = true;
946 logger->info("[DIRECTIVE] tier_change: {}", tc.tier);
947}
948
954void AgentEngine::dir_delegate(
955 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
956 const auto& dl = static_cast<const DelegateDirective&>(d);
957 ctx.pending_delegation = PendingDelegation{
958 dl.target, dl.task, dl.max_turns,
959 dl.resume_from_delegation_id};
960 r.stop_processing = true;
961 if (dl.resume_from_delegation_id.empty()) {
962 logger->info("[DIRECTIVE] delegate: target={} task='{}'",
963 dl.target, dl.task);
964 } else {
965 // gh#32 (v2.1.6): target will be resolved from storage later.
966 logger->info("[DIRECTIVE] resume_delegation: id={} task='{}'",
967 dl.resume_from_delegation_id, dl.task);
968 }
969}
970
976void AgentEngine::dir_pipeline(
977 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
978 const auto& pl = static_cast<const PipelineDirective&>(d);
979 ctx.pending_pipeline = PendingPipeline{pl.stages, pl.task};
980 r.stop_processing = true;
981 logger->info("[DIRECTIVE] pipeline: {} stages", pl.stages.size());
982}
983
994void AgentEngine::dir_complete(
995 LoopContext& ctx, const Directive& d, DirectiveResult& r) {
996 const auto& cd = static_cast<const CompleteDirective&>(d);
997
998 // P1-5 follow-up (2.0.6-rc16.2): signal validating phase so async
999 // subscribers can progress queued/running/running:<tier> → validating.
1000 auto prior_state = ctx.state;
1001 set_state(ctx, AgentState::VERIFYING);
1002
1003 // Fire ON_COMPLETE pre-hook — application can validate/reject
1004 if (fire_complete_hook(cd.summary, ctx)) {
1005 logger->info("[DIRECTIVE] complete REJECTED by hook → revising");
1006 // Revision path: inform subscribers the validator is rewriting.
1007 ctx.metadata["validator_phase"] = "revising";
1008 set_state(ctx, prior_state);
1009 r.stop_processing = false; // don't stop — model should retry
1010 return;
1011 }
1012
1013 ctx.metadata["explicit_completion_summary"] = cd.summary;
1014 // Issue #10 (v2.1.4): persist coverage_gap signal in metadata so
1015 // delegation.cpp::build_child_result can hoist it onto the
1016 // DelegationResult that flows back to the parent. The metadata
1017 // path is the only channel that survives between the child loop
1018 // (which sees the directive) and the parent's
1019 // finalize_delegation_result (which reads the result).
1020 if (cd.coverage_gap) {
1021 ctx.metadata["coverage_gap"] = "true";
1022 ctx.metadata["gap_description"] = cd.gap_description;
1023 nlohmann::json files_arr = cd.suggested_files;
1024 ctx.metadata["suggested_files_json"] = files_arr.dump();
1025 }
1026 set_state(ctx, AgentState::COMPLETE);
1027 r.stop_processing = true;
1028 logger->info("[DIRECTIVE] complete coverage_gap={}",
1029 cd.coverage_gap);
1030}
1031
1037void AgentEngine::dir_clear_todos(
1038 LoopContext&, const Directive&, DirectiveResult&) {
1039 logger->debug("[DIRECTIVE] clear_self_todos (no-op)");
1040}
1041
1047void AgentEngine::dir_inject(
1048 LoopContext&, const Directive& d, DirectiveResult& r) {
1049 const auto& ic = static_cast<const InjectContextDirective&>(d);
1050 if (!ic.content.empty()) {
1051 Message msg;
1052 msg.role = ic.role;
1053 msg.content = ic.content;
1054 r.injected_messages.push_back(std::move(msg));
1055 logger->info("[DIRECTIVE] inject_context");
1056 }
1057}
1058
1064void AgentEngine::dir_prune(
1065 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1066 const auto& pm = static_cast<const PruneMessagesDirective&>(d);
1067 auto [pruned, freed] = context_manager_.prune_tool_results(
1068 ctx, pm.keep_recent);
1069 logger->info("[DIRECTIVE] prune: {} results, {} chars", pruned, freed);
1070}
1071
1077void AgentEngine::dir_anchor(
1078 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1079 const auto& ca = static_cast<const ContextAnchorDirective&>(d);
1080 if (ca.content.empty()) {
1081 context_anchors_.erase(ca.key);
1082 remove_anchor_messages(ctx, ca.key);
1083 logger->info("Removed anchor: {}", ca.key);
1084 return;
1085 }
1086 context_anchors_[ca.key] = ca.content;
1087 remove_anchor_messages(ctx, ca.key);
1088 Message anchor;
1089 anchor.role = "user";
1090 anchor.content = ca.content;
1091 anchor.metadata["is_context_anchor"] = "true";
1092 anchor.metadata["anchor_key"] = ca.key;
1093 ctx.messages.push_back(std::move(anchor));
1094 logger->info("Updated anchor: {}", ca.key);
1095}
1096
1102void AgentEngine::dir_phase(
1103 LoopContext& ctx, const Directive& d, DirectiveResult&) {
1104 const auto& pc = static_cast<const PhaseChangeDirective&>(d);
1105 ctx.active_phase = pc.phase;
1106 logger->info("[DIRECTIVE] phase_change: {}", pc.phase);
1107}
1108
1114void AgentEngine::dir_notify(
1115 LoopContext&, const Directive& d, DirectiveResult&) {
1116 const auto& np = static_cast<const NotifyPresenterDirective&>(d);
1117 if (callbacks_.on_presenter_notify != nullptr) {
1118 callbacks_.on_presenter_notify(
1119 np.key.c_str(), np.data_json.c_str(),
1120 callbacks_.user_data);
1121 }
1122}
1123
1124// ── Tool call parsing (v1.8.5) ───────────────────────────
1125
1142 const nlohmann::json& obj, const std::string& tc_str) {
1143 ToolCall tc;
1144 tc.name = obj.value("name", "");
1145 tc.id = "tc-" + std::to_string(
1146 std::hash<std::string>{}(tc.name + tc_str) & 0xFFFF);
1147 if (obj.contains("arguments") && obj["arguments"].is_object()) {
1148 tc.arguments_json = obj["arguments"].dump();
1149 for (auto& [k, v] : obj["arguments"].items()) {
1150 tc.arguments[k] = v.is_string()
1151 ? v.get<std::string>() : v.dump();
1152 }
1153 }
1154 return tc;
1155}
1156
1164static std::vector<ToolCall> decode_tool_calls_json(
1165 const std::string& tc_str) {
1166 std::vector<ToolCall> calls;
1167 if (tc_str == "[]" || tc_str.empty()) { return calls; }
1168 auto arr = nlohmann::json::parse(tc_str, nullptr, false);
1169 if (!arr.is_array()) { return calls; }
1170 for (const auto& obj : arr) {
1171 calls.push_back(build_tool_call_from_json(obj, tc_str));
1172 }
1173 return calls;
1174}
1175
1188std::pair<std::string, std::vector<ToolCall>>
1189AgentEngine::parse_tool_calls(const std::string& raw_content) {
1190 if (inference_.parse_tool_calls == nullptr) {
1191 return {raw_content, {}};
1192 }
1193
1194 char* cleaned = nullptr;
1195 char* tc_json = nullptr;
1196 int rc = inference_.parse_tool_calls(
1197 raw_content.c_str(), &cleaned, &tc_json,
1198 inference_.adapter_data);
1199
1200 std::string cleaned_str = cleaned ? cleaned : raw_content;
1201 std::string tc_str = tc_json ? tc_json : "[]";
1202
1203 if (inference_.free_fn != nullptr) {
1204 if (cleaned != nullptr) { inference_.free_fn(cleaned); }
1205 if (tc_json != nullptr) { inference_.free_fn(tc_json); }
1206 }
1207
1208 if (rc != 0) { return {cleaned_str, {}}; }
1209 auto calls = decode_tool_calls_json(tc_str);
1210 if (!calls.empty()) {
1211 logger->info("Parsed tool calls from model output");
1212 }
1213 return {cleaned_str, std::move(calls)};
1214}
1215
1223void AgentEngine::process_tool_results(
1224 LoopContext& ctx,
1225 const std::vector<ToolCall>& tool_calls) {
1226 set_state(ctx, AgentState::WAITING_TOOL);
1227
1228 auto results = tool_exec_.process_tool_calls(
1229 ctx, tool_calls, tool_exec_.user_data);
1230
1231 logger->info("[TOOLS] {} call(s) -> {} result message(s)",
1232 tool_calls.size(), results.size());
1233 for (auto& msg : results) {
1234 if (fold_complete_into_assistant(ctx, msg)) {
1235 continue; // gh#68: folded; skip pushing JSON user message
1236 }
1237 ctx.messages.push_back(std::move(msg));
1238 }
1239
1240 ctx.has_pending_tool_results = true;
1241 // Don't overwrite terminal states set by directive handlers
1242 if (ctx.state != AgentState::COMPLETE
1243 && ctx.state != AgentState::ERROR
1244 && ctx.state != AgentState::INTERRUPTED) {
1245 set_state(ctx, AgentState::EXECUTING);
1246 }
1247}
1248
1249// ── Anchor helper ────────────────────────────────────────
1250
1259 const std::string& key) {
1260 auto& msgs = ctx.messages;
1261 msgs.erase(
1262 std::remove_if(msgs.begin(), msgs.end(),
1263 [&key](const Message& m) {
1264 auto it = m.metadata.find("anchor_key");
1265 return it != m.metadata.end() && it->second == key;
1266 }),
1267 msgs.end());
1268}
1269
1270// ── Hook helpers (v1.9.1) ────────────────────────────────
1271
1280bool AgentEngine::fire_pre_hook(
1281 entropic_hook_point_t point, int iteration) {
1282 if (hooks_.fire_pre == nullptr) {
1283 return false;
1284 }
1285 std::string json = "{\"iteration\":"
1286 + std::to_string(iteration) + "}";
1287 char* modified = nullptr;
1288 int rc = hooks_.fire_pre(hooks_.registry,
1289 point, json.c_str(), &modified);
1290 free(modified);
1291 return rc != 0;
1292}
1293
1304static const std::array<const char*, 256>& json_escape_table() {
1305 static const auto tbl = []() {
1306 std::array<const char*, 256> t{};
1307 t[static_cast<unsigned char>('"')] = "\\\"";
1308 t[static_cast<unsigned char>('\\')] = "\\\\";
1309 t[static_cast<unsigned char>('\n')] = "\\n";
1310 t[static_cast<unsigned char>('\r')] = "\\r";
1311 t[static_cast<unsigned char>('\t')] = "\\t";
1312 return t;
1313 }();
1314 return tbl;
1315}
1316
1324static std::string json_escape_engine(const std::string& s) {
1325 const auto& tbl = json_escape_table();
1326 std::string out;
1327 out.reserve(s.size() + 16);
1328 for (char c : s) {
1329 const char* esc = tbl[static_cast<unsigned char>(c)];
1330 if (esc) { out += esc; }
1331 else { out += c; }
1332 }
1333 return out;
1334}
1335
1347static std::string build_tool_manifest(
1348 const std::vector<Message>& messages) {
1349 std::string manifest;
1350 for (const auto& msg : messages) {
1351 auto it = msg.metadata.find("tool_name");
1352 if (it == msg.metadata.end() || it->second.empty()) {
1353 continue;
1354 }
1355 // Issue #5 (v2.1.3): when prune has stubbed this message, the
1356 // original (pre-stub) content size is preserved in
1357 // metadata["original_content"]. Report the real size so the
1358 // validator sees accurate evidence sizes instead of the stub
1359 // length (which is always ~50 chars regardless of original).
1360 auto orig = msg.metadata.find("original_content");
1361 size_t real_size = (orig != msg.metadata.end())
1362 ? orig->second.size()
1363 : msg.content.size();
1364 manifest += "- " + it->second
1365 + " \xe2\x86\x92 " + std::to_string(real_size)
1366 + " chars\n";
1367 }
1368 return manifest;
1369}
1370
1379static std::string format_tool_evidence_entry(const Message& msg,
1380 size_t chars_per_result) {
1381 auto orig = msg.metadata.find("original_content");
1382 const std::string& src = (orig != msg.metadata.end())
1383 ? orig->second
1384 : msg.content;
1385 auto iter = msg.metadata.find("added_at_iteration");
1386 std::string out = "## ";
1387 out += msg.metadata.at("tool_name");
1388 if (iter != msg.metadata.end()) {
1389 out += " [iter " + iter->second + "]";
1390 }
1391 out += "\n";
1392 if (src.size() <= chars_per_result) {
1393 out += src;
1394 } else {
1395 out += src.substr(0, chars_per_result);
1396 out += "\n[... truncated, "
1397 + std::to_string(src.size() - chars_per_result)
1398 + " more chars]";
1399 }
1400 out += "\n\n";
1401 return out;
1402}
1403
1431static std::string build_tool_evidence(
1432 const std::vector<Message>& messages,
1433 size_t max_results,
1434 size_t chars_per_result) {
1435 std::vector<const Message*> tool_msgs;
1436 for (const auto& msg : messages) {
1437 auto it = msg.metadata.find("tool_name");
1438 if (it == msg.metadata.end() || it->second.empty()) {
1439 continue;
1440 }
1441 tool_msgs.push_back(&msg);
1442 }
1443 if (tool_msgs.empty()) { return {}; }
1444
1445 size_t elided = (tool_msgs.size() > max_results)
1446 ? (tool_msgs.size() - max_results) : 0;
1447 size_t start = elided;
1448 std::string out;
1449 if (elided > 0) {
1450 out += "(" + std::to_string(elided)
1451 + " earlier tool results elided for length)\n";
1452 }
1453 for (size_t i = start; i < tool_msgs.size(); ++i) {
1454 out += format_tool_evidence_entry(*tool_msgs[i], chars_per_result);
1455 }
1456 return out;
1457}
1458
1474 const std::string& base,
1475 const ToolExecutionInterface& tool_exec) {
1476 if (tool_exec.history_json == nullptr) { return base; }
1477 char* js = tool_exec.history_json(20, tool_exec.user_data);
1478 if (js == nullptr) { return base; }
1479 std::string out = base + "prior-iteration history: " + js + "\n";
1480 if (tool_exec.free_fn != nullptr) { tool_exec.free_fn(js); }
1481 return out;
1482}
1483
1491static std::string extract_system_prompt(
1492 const std::vector<Message>& messages) {
1493 for (const auto& msg : messages) {
1494 if (msg.role == "system") { return msg.content; }
1495 }
1496 return {};
1497}
1498
1514void AgentEngine::fire_post_generate_hook(
1515 GenerateResult& result,
1516 const std::string& tier,
1517 const std::vector<Message>& messages) {
1518 if (hooks_.fire_post == nullptr) {
1519 return;
1520 }
1521 auto manifest = build_tool_manifest(messages);
1522 manifest = enrich_manifest_with_history(manifest, tool_exec_);
1523 auto sys = extract_system_prompt(messages);
1524 // Issue #5 (v2.1.3): include un-pruned tool-result evidence so the
1525 // constitutional validator can verify file:line citations against
1526 // actual content rather than the stubs that prune leaves behind.
1527 // Bounded at 20 most-recent results × 800 chars each (~16KB max
1528 // evidence), well within any sane critique-prompt budget. The
1529 // field is OPTIONAL on the hook contract — pre-2.1.3 consumers
1530 // ignore it; v2.1.3 validator augments its critique prompt.
1531 auto evidence = build_tool_evidence(messages, 20, 800);
1532 std::string json =
1533 "{\"finish_reason\":\"" + result.finish_reason
1534 + "\",\"content\":\"" + json_escape_engine(result.content)
1535 + "\",\"tier\":\"" + tier
1536 + "\",\"tool_context\":\"" + json_escape_engine(manifest)
1537 + "\",\"tool_evidence\":\"" + json_escape_engine(evidence)
1538 + "\",\"system_prompt\":\"" + json_escape_engine(sys) + "\"}";
1539 char* out = nullptr;
1540 hooks_.fire_post(hooks_.registry,
1541 ENTROPIC_HOOK_POST_GENERATE, json.c_str(), &out);
1542 if (out != nullptr) {
1543 result.content = out;
1544 logger->info("POST_GENERATE hook revised content");
1545 free(out);
1546 }
1547}
1548
1569void AgentEngine::dispatch_post_generate(
1570 LoopContext& ctx, GenerateResult& result) {
1571 // The per-turn reminders built from pending_validation_feedback
1572 // and pending_anti_spiral_warning were just consumed by
1573 // generate_response — clear both so neither leaks into a later
1574 // iteration. (#42, #45 — both one-shot.)
1575 ctx.pending_validation_feedback.clear();
1576 ctx.pending_anti_spiral_warning.clear();
1577 fire_post_generate_hook(result, ctx.locked_tier, ctx.messages);
1578 // After POST_GENERATE the validator may have rejected — stash
1579 // its reason for the NEXT iteration's system prompt.
1580 capture_validation_feedback(ctx);
1581}
1582
1588void AgentEngine::capture_validation_feedback(LoopContext& ctx) {
1589 if (validation_provider_ == nullptr) { return; }
1590 char* v = validation_provider_(validation_provider_data_);
1591 if (v == nullptr) { return; }
1592 std::string raw(v);
1593 free(v);
1594 try {
1595 auto j = nlohmann::json::parse(raw);
1596 auto verdict = j.value("verdict", "");
1597 if (verdict.rfind("rejected", 0) != 0) { return; }
1598 std::string joined;
1599 if (j.contains("violations") && j["violations"].is_array()) {
1600 for (const auto& vio : j["violations"]) {
1601 if (!joined.empty()) { joined += "; "; }
1602 joined += vio.is_string()
1603 ? vio.get<std::string>()
1604 : vio.dump();
1605 }
1606 }
1607 if (joined.empty()) { joined = verdict; }
1608 ctx.pending_validation_feedback = joined;
1609 } catch (const nlohmann::json::exception&) {
1610 // Malformed provider output is non-fatal; just skip.
1611 }
1612}
1613
1625static std::string build_tool_results_json(
1626 const std::vector<Message>& messages) {
1627 std::string arr = "[";
1628 bool first = true;
1629 for (const auto& msg : messages) {
1630 auto it = msg.metadata.find("tool_name");
1631 if (it == msg.metadata.end() || it->second.empty()) {
1632 continue;
1633 }
1634 if (!first) { arr += ","; }
1635 arr += "{\"name\":\"" + json_escape_engine(it->second)
1636 + "\",\"content\":\"" + json_escape_engine(msg.content)
1637 + "\"}";
1638 first = false;
1639 }
1640 arr += "]";
1641 return arr;
1642}
1643
1664bool AgentEngine::fire_complete_hook(
1665 const std::string& summary,
1666 const LoopContext& ctx) {
1667 if (hooks_.fire_pre == nullptr) { return false; }
1668
1669 auto tool_results = build_tool_results_json(ctx.messages);
1670 // E3 (2.0.6-rc17): splice validator verdict/violations so
1671 // ON_COMPLETE consumers can distinguish clean pass from
1672 // reverted-for-length / max-revisions-exhausted.
1673 std::string validation_block = ",\"validation\":null";
1674 if (validation_provider_ != nullptr) {
1675 char* v = validation_provider_(validation_provider_data_);
1676 if (v != nullptr) {
1677 validation_block = ",\"validation\":" + std::string(v);
1678 free(v);
1679 }
1680 }
1681 std::string json =
1682 "{\"summary\":\"" + json_escape_engine(summary)
1683 + "\",\"tier\":\"" + ctx.locked_tier
1684 + "\",\"tool_results\":" + tool_results
1685 + ",\"iteration\":" + std::to_string(ctx.metrics.iterations)
1686 + validation_block
1687 + "}";
1688 char* modified = nullptr;
1689 int rc = hooks_.fire_pre(hooks_.registry,
1690 ENTROPIC_HOOK_ON_COMPLETE, json.c_str(), &modified);
1691 if (modified != nullptr) {
1692 // Hook provided rejection feedback — inject as user message
1693 Message feedback;
1694 feedback.role = "user";
1695 feedback.content = std::string("[CITATION VALIDATION] ") + modified;
1696 const_cast<LoopContext&>(ctx).messages.push_back(
1697 std::move(feedback));
1698 free(modified);
1699 }
1700 return rc != 0;
1701}
1702
1711bool AgentEngine::fire_delegate_pre_hook(
1712 const PendingDelegation& pending, int depth) {
1713 if (hooks_.fire_pre == nullptr) {
1714 return false;
1715 }
1716 std::string json = "{\"target_tier\":\""
1717 + pending.target + "\",\"task\":\""
1718 + pending.task + "\",\"depth\":"
1719 + std::to_string(depth) + "}";
1720 char* modified = nullptr;
1721 int rc = hooks_.fire_pre(hooks_.registry,
1722 ENTROPIC_HOOK_ON_DELEGATE, json.c_str(), &modified);
1723 free(modified);
1724 return rc != 0;
1725}
1726
1743void AgentEngine::fire_delegate_complete_hook(
1744 const std::string& target, bool success,
1745 const std::string& summary) {
1746 if (hooks_.fire_post == nullptr) {
1747 return;
1748 }
1749 nlohmann::json j;
1750 j["target_tier"] = target;
1751 j["success"] = success;
1752 j["result_kind"] = result_kind_to_string(success
1753 ? ToolResultKind::ok
1755 j["summary"] = summary;
1756 std::string json = j.dump();
1757 char* out = nullptr;
1758 hooks_.fire_post(hooks_.registry,
1759 ENTROPIC_HOOK_ON_DELEGATE_COMPLETE, json.c_str(), &out);
1760 free(out);
1761}
1762
1763// ── Delegation execution (v1.8.6) ────────────────────────
1764
1772static void run_child_loop_trampoline(LoopContext& ctx, void* user_data) {
1773 auto* engine = static_cast<AgentEngine*>(user_data);
1774 engine->run_loop(ctx);
1775}
1776
1784 Message reject;
1785 reject.role = "user";
1786 reject.content = "[DELEGATION REJECTED] Maximum delegation "
1787 "depth (" + std::to_string(
1788 AgentEngine::MAX_DELEGATION_DEPTH) +
1789 ") reached.";
1790 ctx.messages.push_back(std::move(reject));
1791}
1792
1804 LoopContext& ctx, const std::string& target) {
1805 std::string chain;
1806 for (const auto& anc : ctx.delegation_ancestor_tiers) {
1807 chain += anc + " -> ";
1808 }
1809 chain += ctx.locked_tier + " -> " + target;
1810
1811 Message reject;
1812 reject.role = "user";
1813 reject.content = "[DELEGATION REJECTED] Circular delegation "
1814 "detected: tier '" + target +
1815 "' already in the active delegation chain "
1816 "(" + chain + "). Choose a different target.";
1817 ctx.metadata["failure_reason"] = "delegation_cycle";
1818 ctx.metadata["failure_target"] = target;
1819 ctx.messages.push_back(std::move(reject));
1820}
1821
1831 const std::string& target, const DelegationResult& result) {
1832 std::string tag = result.success ? "COMPLETE" : "FAILED";
1833 Message msg;
1834 msg.role = "user";
1835 msg.content = "[DELEGATION " + tag + ": " + target + "] " + result.summary;
1836 ctx.messages.push_back(std::move(msg));
1837}
1838
1851 LoopContext& ctx, const std::string& target, int n) {
1852 Message reject;
1853 reject.role = "user";
1854 reject.content = "[DELEGATION REJECTED] '" + target
1855 + "' has just failed " + std::to_string(n)
1856 + " times in a row. Stop retrying this target. Either "
1857 "respond to the user with what you have, or delegate to "
1858 "a different tier.";
1859 ctx.metadata["failure_reason"] = "delegation_repeat_blocked";
1860 ctx.metadata["failure_target"] = target;
1861 ctx.messages.push_back(std::move(reject));
1862}
1863
1872bool AgentEngine::reject_delegation_if_guarded(
1873 LoopContext& ctx, const PendingDelegation& pending) {
1874 bool rejected = true;
1875 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
1876 logger->warn("Delegation rejected: depth {} >= max {}",
1877 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
1878 push_delegation_rejected(ctx);
1879 } else if (is_delegation_cycle(ctx, pending.target)) {
1880 // P1-9: reject A→B→A and longer cycles before running a child.
1881 logger->warn("Delegation rejected: cycle on target tier '{}'",
1882 pending.target);
1883 push_delegation_cycle_rejected(ctx, pending.target);
1884 } else if (is_delegation_repeat_blocked(ctx, pending.target)) {
1885 // gh#64: refuse re-delegation to a target that has just failed
1886 // N times in a row. Pre-fix, a lead retrying a Q4 specialist
1887 // that couldn't emit a tool call would burn 30+ identical
1888 // delegations before its own iteration cap stopped it.
1889 logger->warn("Delegation rejected: '{}' failed {}x in a row "
1890 "(>= max_consecutive_failed_delegations={})",
1891 pending.target,
1892 ctx.consecutive_failed_delegations,
1893 loop_config_.max_consecutive_failed_delegations);
1895 ctx, pending.target, ctx.consecutive_failed_delegations);
1896 } else {
1897 rejected = false;
1898 }
1899 return rejected;
1900}
1901
1914void AgentEngine::execute_pending_delegation(LoopContext& ctx) {
1915 auto pending = std::move(*ctx.pending_delegation);
1916 ctx.pending_delegation.reset();
1917
1918 if (reject_delegation_if_guarded(ctx, pending)) { return; }
1919
1920 set_state(ctx, AgentState::DELEGATING);
1921
1922 // gh#32 (v2.1.6): resume_delegation tool emits a directive with
1923 // resume_from_delegation_id set; the target tier is unknown to the
1924 // tool and must be resolved from storage. Failure to load (unknown
1925 // id, no storage) surfaces a typed error to the lead.
1926 //
1927 // The pre-hook gate is interleaved with resume so both share a
1928 // single early-exit (knots returns gate).
1929 std::vector<Message> resume_history;
1930 bool blocked = false;
1931 if (!pending.resume_from_delegation_id.empty()
1932 && !resolve_resume_delegation(ctx, pending, resume_history)) {
1933 blocked = true;
1934 } else if (fire_delegate_pre_hook(pending, ctx.delegation_depth)) {
1935 logger->info("ON_DELEGATE hook cancelled delegation");
1936 blocked = true;
1937 }
1938 if (blocked) {
1939 set_state(ctx, AgentState::EXECUTING);
1940 return;
1941 }
1942
1943 fire_delegation_start(ctx, pending.target, pending.task);
1944 auto result = run_pending_delegation(
1945 ctx, pending, std::move(resume_history));
1946 push_delegation_result(ctx, pending.target, result);
1947
1948 // gh#64: track consecutive failures against the same target.
1949 if (result.success) {
1950 ctx.last_failed_delegation_target.clear();
1951 ctx.consecutive_failed_delegations = 0;
1952 } else if (pending.target == ctx.last_failed_delegation_target) {
1953 ++ctx.consecutive_failed_delegations;
1954 } else {
1955 ctx.last_failed_delegation_target = pending.target;
1956 ctx.consecutive_failed_delegations = 1;
1957 }
1958
1959 fire_delegation_complete(ctx, pending.target, result);
1960 fire_delegate_complete_hook(pending.target, result.success,
1961 result.summary);
1962
1963 finalize_delegation_result(ctx, result);
1964}
1965
1978void AgentEngine::relay_partial_result(
1979 LoopContext& ctx, const std::string& summary) {
1980 GenerateResult relay_result;
1981 relay_result.content = summary;
1982 relay_result.finish_reason = "stop";
1983 relay_result.tool_calls_json = "[]";
1984 fire_post_generate_hook(
1985 relay_result, ctx.locked_tier, ctx.messages);
1986 ctx.metadata["explicit_completion_summary"] = relay_result.content;
1987 set_state(ctx, AgentState::COMPLETE);
1988}
1989
1997 const std::string& tier, const DelegationResult& result) {
1998 std::string body =
1999 "[COVERAGE GAP from " + tier + "]\n"
2000 "Summary so far: " + result.summary + "\n"
2001 "What's missing: " + result.gap_description;
2002 if (!result.suggested_files.empty()) {
2003 body += "\nSuggested files to inspect:";
2004 for (const auto& f : result.suggested_files) {
2005 body += "\n - " + f;
2006 }
2007 }
2008 return body;
2009}
2010
2031void AgentEngine::finalize_delegation_result(
2032 LoopContext& ctx, const DelegationResult& result) {
2033 // delegation.cpp sets success=false when terminal_reason is non-empty,
2034 // so the two relay branches are disjoint — check them independently.
2035 const bool in_relay_tier =
2036 relay_single_delegate_tiers_.count(ctx.locked_tier) > 0;
2037 if (in_relay_tier && result.coverage_gap) {
2038 Message gap;
2039 gap.role = "user";
2040 gap.content = build_coverage_gap_message(
2041 result.target_tier, result);
2042 ctx.messages.push_back(std::move(gap));
2043 ctx.metadata["relay_status"] = "coverage_gap_suppressed";
2044 logger->info(
2045 "[COVERAGE GAP] suppressing auto-relay for tier={} "
2046 "({} suggested files)",
2047 result.target_tier, result.suggested_files.size());
2048 set_state(ctx, AgentState::EXECUTING);
2049 return;
2050 }
2051 if (result.success && in_relay_tier) {
2052 relay_partial_result(ctx, result.summary);
2053 log_relay_status(ctx);
2054 return;
2055 }
2056 if (!result.terminal_reason.empty() && in_relay_tier) {
2057 relay_partial_result(ctx,
2058 "[partial — budget_exhausted] " + result.summary);
2059 log_relay_status(ctx, result.terminal_reason);
2060 return;
2061 }
2062 bool needs_explicit = tier_requires_explicit_completion(
2063 ctx.locked_tier);
2064 set_state(ctx, (result.success && !needs_explicit)
2065 ? AgentState::COMPLETE : AgentState::EXECUTING);
2066}
2067
2082void AgentEngine::log_relay_status(LoopContext& ctx,
2083 const std::string& terminal_reason) {
2084 std::string verdict;
2085 if (validation_provider_ != nullptr) {
2086 char* v = validation_provider_(validation_provider_data_);
2087 if (v != nullptr) {
2088 std::string jv(v);
2089 free(v);
2090 auto pos = jv.find("\"verdict\":\"");
2091 if (pos != std::string::npos) {
2092 pos += 11;
2093 auto end = jv.find('"', pos);
2094 if (end != std::string::npos) {
2095 verdict = jv.substr(pos, end - pos);
2096 }
2097 }
2098 }
2099 }
2100 if (!terminal_reason.empty()) {
2101 ctx.metadata["relay_status"] = "budget_exhausted_relayed";
2102 logger->warn(
2103 "Relay: single-delegate result used "
2104 "(partial — terminal_reason={}, verdict={})",
2105 terminal_reason, verdict.empty() ? "none" : verdict);
2106 return;
2107 }
2108 if (verdict == "skipped" || verdict.empty()) {
2109 ctx.metadata["relay_status"] = "validation_skipped";
2110 logger->info(
2111 "Relay: single-delegate result used "
2112 "(lead validation skipped per config)");
2113 } else {
2114 ctx.metadata["relay_status"] = "validated";
2115 logger->info(
2116 "Relay: single-delegate result used "
2117 "(passed lead validation)");
2118 }
2119}
2120
2127void AgentEngine::execute_pending_pipeline(LoopContext& ctx) {
2128 auto pending = std::move(*ctx.pending_pipeline);
2129 ctx.pending_pipeline.reset();
2130
2131 if (ctx.delegation_depth >= MAX_DELEGATION_DEPTH) {
2132 logger->warn("Pipeline rejected: depth {} >= max {}",
2133 ctx.delegation_depth, MAX_DELEGATION_DEPTH);
2134 Message reject;
2135 reject.role = "user";
2136 reject.content = "[PIPELINE REJECTED] Maximum delegation "
2137 "depth reached.";
2138 ctx.messages.push_back(std::move(reject));
2139 return;
2140 }
2141
2142 set_state(ctx, AgentState::DELEGATING);
2143
2144 // gh#33 (v2.1.6): engine-scoped sandbox; non-owning pointer.
2145 auto repo_dir = get_repo_dir();
2146 DelegationManager mgr(run_child_loop_trampoline, this,
2147 tier_res_, repo_dir,
2148 ensure_sandbox_manager());
2149 if (storage_.create_delegation != nullptr) {
2150 mgr.set_storage(&storage_);
2151 }
2152 // gh#29 (v2.1.5): snapshot under the mutex (see execute_pending_
2153 // delegation comment); avoids a torn read against a concurrent
2154 // set_delegation_callbacks call.
2155 auto cb_snap = delegation_callbacks_snapshot();
2156 mgr.set_delegation_callbacks(
2157 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2158 auto result = mgr.execute_pipeline(
2159 ctx, pending.stages, pending.task);
2160
2161 std::string tag = result.success ? "COMPLETE" : "FAILED";
2162 Message result_msg;
2163 result_msg.role = "user";
2164 result_msg.content = "[PIPELINE " + tag + "] " + result.summary;
2165 ctx.messages.push_back(std::move(result_msg));
2166
2167 set_state(ctx, AgentState::EXECUTING);
2168}
2169
2178void AgentEngine::fire_delegation_start(
2179 const LoopContext& /*ctx*/,
2180 const std::string& tier,
2181 const std::string& task) {
2182 if (callbacks_.on_delegation_start != nullptr) {
2183 callbacks_.on_delegation_start(
2184 "", tier.c_str(), task.c_str(),
2185 callbacks_.user_data);
2186 }
2187}
2188
2197void AgentEngine::fire_delegation_complete(
2198 const LoopContext& /*ctx*/,
2199 const std::string& tier,
2200 const DelegationResult& result) {
2201 if (callbacks_.on_delegation_complete != nullptr) {
2202 callbacks_.on_delegation_complete(
2203 "", tier.c_str(), result.summary.c_str(),
2204 result.success ? 1 : 0,
2205 callbacks_.user_data);
2206 }
2207}
2208
2209// ── Auto-chain (v1.8.6) ─────────────────────────────────
2210
2220bool AgentEngine::should_auto_chain(
2221 const LoopContext& ctx,
2222 const std::string& finish_reason,
2223 const std::string& content) {
2224 if (ctx.locked_tier.empty() || tier_res_.get_tier_param == nullptr) {
2225 return false;
2226 }
2227
2228 std::string auto_chain = tier_res_.get_tier_param(
2229 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2230 if (auto_chain.empty()) {
2231 return false;
2232 }
2233
2234 bool triggered = (finish_reason == "length") ||
2235 (finish_reason == "stop" &&
2236 response_generator_.is_response_complete(content, "[]"));
2237 return triggered;
2238}
2239
2249bool AgentEngine::try_auto_chain(
2250 LoopContext& ctx,
2251 const std::string& finish_reason,
2252 const std::string& content) {
2253 if (!should_auto_chain(ctx, finish_reason, content)) {
2254 return false;
2255 }
2256
2257 if (ctx.delegation_depth > 0) {
2258 logger->info("[AUTO-CHAIN] child depth={}, completing",
2259 ctx.delegation_depth);
2260 set_state(ctx, AgentState::COMPLETE);
2261 return true;
2262 }
2263
2264 // Root: tier change to auto_chain target
2265 std::string target = tier_res_.get_tier_param(
2266 ctx.locked_tier, "auto_chain", tier_res_.user_data);
2267
2268 if (!target.empty()) {
2269 logger->info("[AUTO-CHAIN] root, tier change to '{}'", target);
2270 TierChangeDirective tc(target, "auto_chain");
2271 DirectiveResult r;
2272 dir_tier_change(ctx, tc, r);
2273 }
2274 return !target.empty();
2275}
2276
2277// ── Project dir (v2.1.5, gh#29) ─────────────────────────
2278
2299std::filesystem::path AgentEngine::get_repo_dir() {
2300 if (repo_dir_checked_) {
2301 return cached_repo_dir_.value_or(std::filesystem::path{});
2302 }
2303 repo_dir_checked_ = true;
2304 std::filesystem::path resolved;
2305 if (!project_dir_override_.empty()) {
2306 resolved = project_dir_override_;
2307 logger->info("Project dir for sandbox snapshots: {} (configured)",
2308 resolved.string());
2309 } else {
2310 resolved = std::filesystem::current_path();
2311 logger->info("Project dir for sandbox snapshots: {} (cwd fallback)",
2312 resolved.string());
2313 }
2314 cached_repo_dir_ = resolved;
2315 return resolved;
2316}
2317
2329void AgentEngine::set_project_dir(const std::filesystem::path& project_dir) {
2330 project_dir_override_ = project_dir;
2331 cached_repo_dir_.reset();
2332 repo_dir_checked_ = false;
2333}
2334
2351 LoopContext& ctx,
2352 const std::string& reason,
2353 const std::string& delegation_id) {
2354 Message m;
2355 m.role = "user";
2356 m.content = "[DELEGATION FAILED: resume_delegation] "
2357 + reason + " (delegation_id=" + delegation_id + ")";
2358 ctx.messages.push_back(std::move(m));
2359}
2360
2374bool AgentEngine::fetch_resume_payload(
2375 LoopContext& ctx,
2376 const std::string& id,
2377 nlohmann::json& parsed) {
2378 std::optional<std::string> error;
2379 std::string raw;
2380 if (storage_.load_delegation_with_messages == nullptr) {
2381 error = "storage unavailable";
2382 } else if (!storage_.load_delegation_with_messages(
2383 id.c_str(), raw, storage_.user_data)) {
2384 error = "unknown delegation_id";
2385 } else {
2386 parsed = nlohmann::json::parse(raw, nullptr, false);
2387 if (parsed.is_discarded() || !parsed.is_object()) {
2388 error = "malformed storage payload";
2389 }
2390 }
2391 if (error.has_value()) {
2392 logger->error("resume_delegation '{}': {}", id, *error);
2393 push_resume_failure(ctx, *error, id);
2394 return false;
2395 }
2396 return true;
2397}
2398
2404bool AgentEngine::resolve_resume_delegation(
2405 LoopContext& ctx,
2406 PendingDelegation& pending,
2407 std::vector<Message>& out_history) {
2408 const auto& id = pending.resume_from_delegation_id;
2409 nlohmann::json j;
2410 if (!fetch_resume_payload(ctx, id, j)) {
2411 return false;
2412 }
2413 auto target = j.value("target_tier", std::string{});
2414 if (target.empty()) {
2415 push_resume_failure(ctx, "target_tier missing in storage", id);
2416 return false;
2417 }
2418 pending.target = target;
2419 if (j.contains("messages") && j["messages"].is_array()) {
2420 for (const auto& mj : j["messages"]) {
2421 Message m;
2422 m.role = mj.value("role", "");
2423 m.content = mj.value("content", "");
2424 if (!m.role.empty()) {
2425 out_history.push_back(std::move(m));
2426 }
2427 }
2428 }
2429 logger->info("resume_delegation '{}': loaded {} messages, target='{}'",
2430 id, out_history.size(), target);
2431 return true;
2432}
2433
2448DelegationResult AgentEngine::run_pending_delegation(
2449 LoopContext& ctx,
2450 const PendingDelegation& pending,
2451 std::vector<Message> resume_history) {
2452 std::optional<int> max_turns;
2453 if (pending.max_turns > 0) { max_turns = pending.max_turns; }
2454 DelegationManager mgr(run_child_loop_trampoline, this,
2455 tier_res_, get_repo_dir(),
2456 ensure_sandbox_manager());
2457 if (storage_.create_delegation != nullptr) {
2458 mgr.set_storage(&storage_);
2459 }
2460 auto cb_snap = delegation_callbacks_snapshot();
2461 mgr.set_delegation_callbacks(
2462 cb_snap.start, cb_snap.complete, cb_snap.user_data);
2463 if (resume_history.empty()) {
2464 return mgr.execute_delegation(
2465 ctx, pending.target, pending.task, max_turns);
2466 }
2467 return mgr.execute_resume_delegation(
2468 ctx, pending.target, pending.task,
2469 std::move(resume_history), max_turns);
2470}
2471
2477SandboxManager* AgentEngine::ensure_sandbox_manager() {
2478 if (sandbox_mgr_) {
2479 return &*sandbox_mgr_;
2480 }
2481 auto repo_dir = get_repo_dir();
2482 if (repo_dir.empty()) {
2483 return nullptr;
2484 }
2485 sandbox_mgr_.emplace(repo_dir);
2486 return &*sandbox_mgr_;
2487}
2488
2489// ── Conversation state (v2.0.2) ─────────────────────────────
2490
2497void AgentEngine::set_system_prompt(const std::string& prompt) {
2498 system_prompt_ = prompt;
2499}
2500
2507void AgentEngine::set_session_logger(SessionLogger* log) {
2508 session_logger_ = log;
2509}
2510
2523std::vector<Message> AgentEngine::run_turn(const std::string& input) {
2524 // gh#40 (v2.1.10): wrap the agent loop in a drain loop so any
2525 // user messages enqueued mid-generation become subsequent turns
2526 // at this single structural top-level COMPLETE boundary. The
2527 // running_flag_ gate lets the facade reject
2528 // entropic_queue_user_message with INVALID_STATE when no run is
2529 // in flight (validation criterion #1).
2530 running_flag_.store(true);
2531 if (conversation_.empty() && !system_prompt_.empty()) {
2532 Message sys;
2533 sys.role = "system";
2534 sys.content = system_prompt_;
2535 conversation_.push_back(std::move(sys));
2536 }
2537 std::string pending = input;
2538 std::vector<Message> result;
2539 while (true) {
2540 Message usr;
2541 usr.role = "user";
2542 usr.content = pending;
2543 conversation_.push_back(std::move(usr));
2544
2545 size_t sent_len = conversation_.size();
2546 result = run(conversation_); // copy — run() may mutate
2547 for (size_t i = sent_len; i < result.size(); ++i) {
2548 conversation_.push_back(result[i]);
2549 }
2550 auto next = pop_queued_user_message();
2551 if (!next.has_value()) { break; }
2552 fire_queue_consumed(*next, user_message_queue_depth());
2553 pending = std::move(*next);
2554 }
2555 running_flag_.store(false);
2556 return result;
2557}
2558
2581void AgentEngine::seed_system_prompt(
2582 const std::vector<Message>& new_messages) {
2583 bool caller_has_system = false;
2584 for (const auto& m : new_messages) {
2585 if (m.role == "system") { caller_has_system = true; break; }
2586 }
2587 if (conversation_.empty() && !system_prompt_.empty()
2588 && !caller_has_system) {
2589 Message sys;
2590 sys.role = "system";
2591 sys.content = system_prompt_;
2592 conversation_.push_back(std::move(sys));
2593 }
2594}
2595
2603bool AgentEngine::prepare_next_turn(std::vector<Message>& pending) {
2604 auto next = pop_queued_user_message();
2605 if (!next.has_value()) { return false; }
2606 fire_queue_consumed(*next, user_message_queue_depth());
2607 Message usr;
2608 usr.role = "user";
2609 usr.content = std::move(*next);
2610 pending.clear();
2611 pending.push_back(std::move(usr));
2612 return true;
2613}
2614
2622std::vector<Message> AgentEngine::run_turn(std::vector<Message> new_messages) {
2623 // gh#40 (v2.1.10): mirror the single-string overload's drain
2624 // loop. Queued messages enqueued via entropic_queue_user_message
2625 // become subsequent plain-text user turns at this top-level
2626 // boundary (no content_parts — the queue ABI is text-only).
2627 running_flag_.store(true);
2628 seed_system_prompt(new_messages);
2629 std::vector<Message> pending = std::move(new_messages);
2630 std::vector<Message> result;
2631 while (true) {
2632 for (auto& m : pending) {
2633 conversation_.push_back(std::move(m));
2634 }
2635 size_t sent_len = conversation_.size();
2636 result = run(conversation_);
2637 for (size_t i = sent_len; i < result.size(); ++i) {
2638 conversation_.push_back(result[i]);
2639 }
2640 if (!prepare_next_turn(pending)) { break; }
2641 }
2642 running_flag_.store(false);
2643 return result;
2644}
2645
2656int AgentEngine::run_streaming(
2657 const std::string& input,
2658 TokenCallback on_token,
2659 void* user_data,
2660 int* cancel_flag)
2661{
2662 if (session_logger_) {
2663 session_logger_->log_user_input(input);
2664 }
2665
2666 StreamThinkFilter filter(on_token, user_data);
2667 if (session_logger_ && session_logger_->is_open()) {
2668 filter.set_raw_callback(
2669 SessionLogger::raw_token_callback,
2670 session_logger_);
2671 }
2672
2673 struct Ctx {
2674 StreamThinkFilter* filter;
2675 int* cancel;
2676 AgentEngine* engine;
2677 };
2678 Ctx sctx{&filter, cancel_flag, this};
2679
2680 EngineCallbacks cbs{};
2681 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
2682 auto* c = static_cast<Ctx*>(ud);
2683 if (c->cancel && *c->cancel) {
2684 c->engine->interrupt();
2685 return;
2686 }
2687 c->filter->on_token(t, l);
2688 };
2689 cbs.user_data = &sctx;
2690 set_callbacks(cbs);
2691
2692 auto result = run_turn(input);
2693 filter.flush();
2694
2695 if (session_logger_) {
2696 session_logger_->end_turn();
2697 }
2698 if (cancel_flag && *cancel_flag) { return 1; }
2699 return 0;
2700}
2701
2709static std::string concat_user_echo(
2710 const std::vector<Message>& messages) {
2711 std::string echo;
2712 for (const auto& m : messages) {
2713 if (m.role != "user" || m.content.empty()) { continue; }
2714 if (!echo.empty()) { echo += '\n'; }
2715 echo += m.content;
2716 }
2717 return echo;
2718}
2719
2735int AgentEngine::run_streaming(
2736 std::vector<Message> new_messages,
2737 TokenCallback on_token,
2738 void* user_data,
2739 int* cancel_flag)
2740{
2741 if (session_logger_) {
2742 session_logger_->log_user_input(concat_user_echo(new_messages));
2743 }
2744
2745 StreamThinkFilter filter(on_token, user_data);
2746 if (session_logger_ && session_logger_->is_open()) {
2747 filter.set_raw_callback(
2748 SessionLogger::raw_token_callback,
2749 session_logger_);
2750 }
2751
2752 struct Ctx {
2753 StreamThinkFilter* filter;
2754 int* cancel;
2755 AgentEngine* engine;
2756 };
2757 Ctx sctx{&filter, cancel_flag, this};
2758
2759 EngineCallbacks cbs{};
2760 cbs.on_stream_chunk = [](const char* t, size_t l, void* ud) {
2761 auto* c = static_cast<Ctx*>(ud);
2762 if (c->cancel && *c->cancel) {
2763 c->engine->interrupt();
2764 return;
2765 }
2766 c->filter->on_token(t, l);
2767 };
2768 cbs.user_data = &sctx;
2769 set_callbacks(cbs);
2770
2771 auto result = run_turn(std::move(new_messages));
2772 filter.flush();
2773
2774 if (session_logger_) {
2775 session_logger_->end_turn();
2776 }
2777 if (cancel_flag && *cancel_flag) { return 1; }
2778 return 0;
2779}
2780
2786void AgentEngine::clear_conversation() {
2787 conversation_.clear();
2788 logger->info("conversation cleared");
2789}
2790
2797size_t AgentEngine::message_count() const {
2798 return conversation_.size();
2799}
2800
2807const std::vector<Message>& AgentEngine::get_messages() const {
2808 return conversation_;
2809}
2810
2811// ── Mid-generation user-message queue (gh#40, v2.1.10) ─────────
2812
2818bool AgentEngine::queue_user_message(const std::string& message) {
2819 std::lock_guard lock(queue_mutex_);
2820 int cap = loop_config_.message_queue_capacity;
2821 if (cap < 0) { cap = 0; }
2822 if (user_message_queue_.size()
2823 >= static_cast<size_t>(cap)) {
2824 return false;
2825 }
2826 user_message_queue_.push_back(message);
2827 logger->info("queued mid-gen user message: depth={}",
2828 user_message_queue_.size());
2829 return true;
2830}
2831
2837size_t AgentEngine::user_message_queue_depth() const {
2838 std::lock_guard lock(queue_mutex_);
2839 return user_message_queue_.size();
2840}
2841
2847void AgentEngine::clear_user_message_queue() {
2848 std::lock_guard lock(queue_mutex_);
2849 size_t dropped = user_message_queue_.size();
2850 user_message_queue_.clear();
2851 if (dropped > 0) {
2852 logger->info("cleared mid-gen queue: dropped={}", dropped);
2853 }
2854}
2855
2861void AgentEngine::set_message_queue_capacity(int cap) {
2862 std::lock_guard lock(queue_mutex_);
2863 loop_config_.message_queue_capacity = cap < 0 ? 0 : cap;
2864}
2865
2871std::optional<std::string>
2872AgentEngine::pop_queued_user_message() {
2873 std::lock_guard lock(queue_mutex_);
2874 if (user_message_queue_.empty()) {
2875 return std::nullopt;
2876 }
2877 std::string front = std::move(user_message_queue_.front());
2878 user_message_queue_.pop_front();
2879 return front;
2880}
2881
2887void AgentEngine::fire_queue_consumed(const std::string& consumed,
2888 size_t remaining) {
2889 if (queue_observer_ != nullptr) {
2890 queue_observer_(
2891 consumed.c_str(), remaining, queue_observer_data_);
2892 }
2893}
2894
2900void AgentEngine::set_queue_observer(
2901 void (*observer)(const char*, size_t, void*),
2902 void* user_data) {
2903 queue_observer_ = observer;
2904 queue_observer_data_ = user_data;
2905}
2906
2918void AgentEngine::set_state_observer(
2919 void (*observer)(int, void*),
2920 void* user_data) {
2921 state_observer_ = observer;
2922 state_observer_data_ = user_data;
2923 response_generator_.set_state_observer(observer, user_data);
2924}
2925
2926// ── Directive hooks (v2.0.2) ────────────────────────────────
2927
2934ToolExecutorHooks AgentEngine::build_directive_hooks() {
2935 ToolExecutorHooks hooks;
2936 hooks.process_directives = [](
2937 LoopContext& ctx,
2938 const std::vector<const Directive*>& dirs,
2939 void* ud) -> DirectiveResult {
2940 return static_cast<AgentEngine*>(ud)
2941 ->directive_processor().process(ctx, dirs);
2942 };
2943 hooks.user_data = this;
2944 return hooks;
2945}
2946
2947// ── Tier resolution (v2.0.2) ────────────────────────────────
2948
2956void AgentEngine::set_tier_info(
2957 const std::string& name,
2958 const ChildContextInfo& info)
2959{
2960 tier_info_[name] = info;
2961}
2962
2969void AgentEngine::set_relay_single_delegate(const std::string& name) {
2970 relay_single_delegate_tiers_.insert(name);
2971}
2972
2979void AgentEngine::set_handoff_rules(
2980 const std::unordered_map<std::string,
2981 std::vector<std::string>>& rules)
2982{
2983 handoff_rules_ = rules;
2984 wire_internal_tier_resolution();
2985}
2986
2992ChildContextInfo AgentEngine::tri_resolve_tier(
2993 const std::string& name, void* ud) {
2994 auto* self = static_cast<AgentEngine*>(ud);
2995 auto it = self->tier_info_.find(name);
2996 ChildContextInfo info;
2997 if (it == self->tier_info_.end()) {
2998 info.valid = false;
2999 } else {
3000 info = it->second;
3001 }
3002 return info;
3003}
3004
3013bool AgentEngine::tri_tier_exists(const std::string& name, void* ud) {
3014 auto* self = static_cast<AgentEngine*>(ud);
3015 return self->tier_info_.count(name) > 0;
3016}
3017
3026std::vector<std::string> AgentEngine::tri_get_handoff_targets(
3027 const std::string& name, void* ud) {
3028 auto* self = static_cast<AgentEngine*>(ud);
3029 auto it = self->handoff_rules_.find(name);
3030 std::vector<std::string> result;
3031 if (it != self->handoff_rules_.end()) { result = it->second; }
3032 return result;
3033}
3034
3058std::string AgentEngine::tri_get_tier_param(const std::string& name,
3059 const std::string& param, void* ud) {
3060 auto* self = static_cast<AgentEngine*>(ud);
3061 auto it = self->tier_info_.find(name);
3062 if (it == self->tier_info_.end()) { return ""; }
3063 const auto& info = it->second;
3064 std::string result;
3065 if (param == "explicit_completion") {
3066 result = info.explicit_completion ? "true" : "false";
3067 } else if (param == "max_iterations"
3068 && info.max_iterations_override >= 0) {
3069 result = std::to_string(info.max_iterations_override);
3070 } else if (param == "max_tool_calls_per_turn"
3071 && info.max_tool_calls_per_turn_override >= 0) {
3072 result = std::to_string(info.max_tool_calls_per_turn_override);
3073 }
3074 return result;
3075}
3076
3082void AgentEngine::wire_internal_tier_resolution() {
3083 TierResolutionInterface tri;
3084 tri.resolve_tier = &AgentEngine::tri_resolve_tier;
3085 tri.tier_exists = &AgentEngine::tri_tier_exists;
3086 tri.get_handoff_targets = &AgentEngine::tri_get_handoff_targets;
3087 tri.get_tier_param = &AgentEngine::tri_get_tier_param;
3088 tri.user_data = this;
3089 set_tier_resolution(tri);
3090}
3091
3092} // namespace entropic
Core agent execution engine.
Definition engine.h:62
void run_loop(LoopContext &ctx)
Run the engine loop on a pre-built context.
Definition engine.cpp:342
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:870
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:1996
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:1491
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:1141
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:1625
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:1164
static std::string json_escape_engine(const std::string &s)
JSON-escape a string (no surrounding quotes).
Definition engine.cpp:1324
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:1431
static void remove_anchor_messages(LoopContext &ctx, const std::string &key)
Remove messages with a specific anchor key.
Definition engine.cpp:1258
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:1850
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:1347
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:1379
static const std::array< const char *, 256 > & json_escape_table()
256-entry table mapping bytes to JSON escape sequences.
Definition engine.cpp:1304
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:1473
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:1830
static void run_child_loop_trampoline(LoopContext &ctx, void *user_data)
Trampoline for DelegationManager to call engine loop.
Definition engine.cpp:1772
const char * result_kind_to_string(ToolResultKind kind)
Serialize a ToolResultKind to its wire-stable string form.
Definition tool_result.h:49
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:1783
static std::string concat_user_echo(const std::vector< Message > &messages)
Concatenate user-role message text for session-log echo.
Definition engine.cpp:2709
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:2350
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:1803
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:884
Result of a finalized delegation, delivered to the consumer.
Definition entropic.h:907
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:508
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.
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.