Entropic 2.11.1
Local-first agentic inference engine
Loading...
Searching...
No Matches
response_generator.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
12
13#include <nlohmann/json.hpp>
14
15#include <atomic>
16#include <chrono>
17#include <cstring>
18#include <functional>
19#include <thread>
20#include <unordered_map>
21
22static auto logger = entropic::log::get("core.response_generator");
23
24namespace entropic {
25
27static std::unordered_map<std::string, size_t> s_tier_system_hash;
28
40static void log_prompt(const std::vector<Message>& messages,
41 const std::string& tier) {
42 logger->info("─── Prompt ({} messages, tier={}) ───",
43 messages.size(), tier);
44 for (size_t i = 0; i < messages.size(); ++i) {
45 if (messages[i].role == "system") {
46 size_t h = std::hash<std::string>{}(messages[i].content);
47 size_t prev = s_tier_system_hash[tier];
48 s_tier_system_hash[tier] = h;
49 if (h != prev || prev == 0) {
50 logger->info("[{}] role=system hash={:016x} "
51 "prev={:016x}\n{}",
52 i, h, prev, messages[i].content);
53 } else {
54 logger->info("[{}] role=system [unchanged, {} chars, "
55 "hash={:016x}]",
56 i, messages[i].content.size(), h);
57 }
58 } else {
59 logger->info("[{}] role={}\n{}", i, messages[i].role,
60 messages[i].content);
61 }
62 }
63 logger->info("─── End prompt ───");
64}
65
76 const InferenceInterface& inference,
77 const LoopConfig& loop_config,
78 EngineCallbacks& callbacks,
79 GenerationEvents events)
80 : inference_(inference),
81 loop_config_(loop_config),
82 callbacks_(callbacks),
83 events_(events) {}
84
93 lock_tier_if_needed(ctx);
94
95 if (loop_config_.stream_output) {
96 return generate_streaming(ctx);
97 }
98 return generate_batch(ctx);
99}
100
110 const std::string& content,
111 const std::string& tool_calls_json) {
112 if (inference_.is_response_complete == nullptr) {
113 return !content.empty();
114 }
115 return inference_.is_response_complete(
116 content.c_str(), tool_calls_json.c_str(),
117 inference_.adapter_data) != 0;
118}
119
126void ResponseGenerator::lock_tier_if_needed(LoopContext& ctx) {
127 if (!ctx.locked_tier.empty()) {
128 if (callbacks_.on_tier_selected != nullptr) {
129 callbacks_.on_tier_selected(ctx.locked_tier.c_str(),
130 callbacks_.user_data);
131 }
132 return;
133 }
134
135 if (inference_.route == nullptr) {
136 ctx.locked_tier = "default";
137 return;
138 }
139
140 auto msgs_json = serialize_messages(ctx.messages);
141 char* result_json = nullptr;
142 int rc = inference_.route(msgs_json.c_str(), &result_json,
143 inference_.orchestrator_data);
144 if (rc == 0 && result_json != nullptr) {
145 ctx.locked_tier = result_json;
146 if (inference_.free_fn != nullptr) {
147 inference_.free_fn(result_json);
148 }
149 } else {
150 ctx.locked_tier = "default";
151 logger->warn("Routing failed (rc={}), using default tier", rc);
152 }
153
154 logger->info("Locked tier: {}", ctx.locked_tier);
155 if (callbacks_.on_tier_selected != nullptr) {
156 callbacks_.on_tier_selected(ctx.locked_tier.c_str(),
157 callbacks_.user_data);
158 }
159}
160
161// ── Streaming token accumulator context ──────────────────
162
169 std::string content;
172 const HookInterface* hooks;
173 int token_index = 0;
174 bool interrupted = false;
180 int* cancel_flag = nullptr;
183 void (*observer)(const char*, size_t, void*) = nullptr;
184 void* observer_data = nullptr;
185};
186
196 const char* token,
197 size_t len,
198 void* user_data) {
199 auto* acc = static_cast<StreamAccumulator*>(user_data);
200
201 // gh#20 (v2.1.5): two coupled bugs lived here.
202 //
203 // (A) The previous implementation set `acc->interrupted = true`
204 // and returned early WITHOUT propagating the interrupt to the
205 // backend. The backend's cancel_flag stayed 0, so llama_cpp
206 // ran to natural EOS — up to 60s of wasted decode after the
207 // user pressed Ctrl-C.
208 //
209 // (B) The early return also dropped every post-interrupt token
210 // from `acc->content`. When the backend finally finished
211 // cleanly, the response_generator built the iter result from
212 // this truncated buffer (e.g. 7 chars instead of the 107
213 // decoded tokens forming a valid tool call), throwing away
214 // fully-formed output.
215 //
216 // The fix raises the cancel flag for the backend AND keeps
217 // appending the token so the content buffer is complete up to
218 // the cancel point. The backend stops on its next loop iteration
219 // (<= 1 token wall-time); whatever made it through is preserved.
220 bool just_interrupted = acc->events->interrupt != nullptr
221 && acc->events->interrupt->load()
222 && !acc->interrupted;
223 if (just_interrupted) {
224 acc->interrupted = true;
225 // gh#49 (v2.1.12): log the cancel-flag raise so a session
226 // log can confirm the per-token interrupt poll observed the
227 // engine-level flag. Pre-v2.1.12 the bissell-llm-studio
228 // repro saw the "Engine interrupted" line on 0->1 transition
229 // but no evidence the per-token poll ever fired — this log
230 // is the first observable receipt of the propagation.
231 logger->info("Stream interrupt observed at token {}; "
232 "raising backend cancel_flag",
233 acc->token_index);
234 if (acc->cancel_flag != nullptr) {
235 *acc->cancel_flag = 1;
236 }
237 }
238
239 acc->content.append(token, len);
240 if (acc->callbacks->on_stream_chunk != nullptr) {
241 acc->callbacks->on_stream_chunk(token, len,
242 acc->callbacks->user_data);
243 }
244
245 // Global observer — fires on every token regardless of whether
246 // the caller registered on_stream_chunk. (2.0.6-rc16)
247 if (acc->observer != nullptr) {
248 acc->observer(token, len, acc->observer_data);
249 }
250
251 // Hook: ON_STREAM_TOKEN (v1.9.1)
252 if (acc->hooks != nullptr && acc->hooks->fire_info != nullptr) {
253 std::string json = "{\"token_index\":"
254 + std::to_string(acc->token_index++) + "}";
255 acc->hooks->fire_info(acc->hooks->registry,
256 ENTROPIC_HOOK_ON_STREAM_TOKEN, json.c_str());
257 }
258}
259
273static std::string resolve_stream_finish_reason(int rc,
274 size_t content_size) {
275 std::string reason;
276 if (rc == ENTROPIC_ERROR_CANCELLED) {
277 logger->info("Stream cancelled by interrupt after {} chars",
278 content_size);
279 reason = "interrupted";
280 } else if (rc != 0 && content_size > 0) {
281 logger->warn("Stream failed (rc={}) after {} chars — "
282 "preserving partial", rc, content_size);
283 reason = "partial";
284 } else if (rc != 0) {
285 logger->error("Stream failed (rc={}) with no partial content", rc);
286 reason = "error";
287 } else {
288 reason = "stop";
289 }
290 return reason;
291}
292
301std::pair<std::string, std::string> ResponseGenerator::prepare_prompts(
302 LoopContext& ctx, const char* mode) {
303 // gh#87 (v2.7.0): tool defs no longer string-injected into the system
304 // message — they flow as structured JSON via build_params_json →
305 // params.tools, and common_chat renders them in the model's native
306 // format. Only the runtime engine-state reminder is injected here.
307 auto messages = inject_engine_state_reminder(ctx.messages, ctx);
308 logger->info("Generate ({}): tier={}, {} messages",
309 mode, ctx.locked_tier, messages.size());
310 log_prompt(messages, ctx.locked_tier);
311 return {serialize_messages(messages),
312 build_params_json(ctx.locked_tier)};
313}
314
323GenerateResult ResponseGenerator::generate_streaming(LoopContext& ctx) {
324 if (inference_.generate_stream == nullptr) {
325 logger->warn("No streaming function, falling back to batch");
326 return generate_batch(ctx);
327 }
328
329 auto [msgs_json, params_json] = prepare_prompts(ctx, "stream");
330
331 int cancel_flag = 0;
332 StreamAccumulator acc;
333 acc.callbacks = &callbacks_;
334 acc.events = &events_;
335 acc.hooks = &hooks_;
336 // gh#20 (v2.1.5): give the token callback a path to raise the
337 // backend cancel flag when an interrupt is observed. Without
338 // this, the previous implementation would early-return out of
339 // the token callback without ever telling the backend to stop.
340 acc.cancel_flag = &cancel_flag;
341 // Wire the persistent stream observer so every token — including
342 // batch entropic_run and delegate child-loop generations — reaches
343 // any registered observer. (P0-1, 2.0.6-rc16)
344 acc.observer = stream_observer_;
345 acc.observer_data = stream_observer_data_;
346
347 int rc = inference_.generate_stream(
348 msgs_json.c_str(), params_json.c_str(),
350 &cancel_flag, inference_.backend_data);
351
352 GenerateResult result;
353 result.finish_reason = resolve_stream_finish_reason(rc,
354 acc.content.size());
355 // Issue #3 (v2.1.1): inbound boundary from llama_cpp. Models can emit
356 // malformed UTF-8 mid-stream (partial multi-byte runs under XML-tool-call
357 // pressure, decoder desyncs). Sanitize ONCE at message-finalization,
358 // never per-token — a multi-byte codepoint may split across token
359 // boundaries and per-token sanitize would corrupt valid output.
360 // See include/entropic/mcp/utf8_sanitize.h for the boundary policy.
361 result.content = mcp::sanitize_utf8(acc.content);
362 result.tool_calls_json = "[]";
363 logger->info("Generate complete (stream): finish={}, {} chars",
364 result.finish_reason, result.content.size());
365 return result;
366}
367
382int ResponseGenerator::dispatch_batch_generate(
383 const std::string& msgs_json,
384 const std::string& params_json,
385 char** result_json) {
386 // Fall back to the no-cancel entry for backends that predate the
387 // generate_cancellable ABI field.
388 //
389 // gh#110 (v2.9.6): also prefer the no-cancel entry whenever
390 // speculative decoding is enabled. `generate_cancellable`'s
391 // backing orchestrator call deliberately bypasses
392 // run_generate_dispatch (speculative/MTP routing) — batch-with-
393 // cancel only ever calls plain decode. The plain `generate` entry
394 // point runs run_generate_dispatch and is therefore the only
395 // batch path that can reach MTP. v1 tradeoff: a speculative batch
396 // turn is not cancellable mid-decode (documented, not silent).
397 if (inference_.generate_cancellable == nullptr
398 || loop_config_.speculative_enabled) {
399 return inference_.generate(
400 msgs_json.c_str(), params_json.c_str(),
401 result_json, inference_.backend_data);
402 }
403
404 // The C-ABI side bridges this int → atomic<bool> for the backend
405 // via its own poller; this side mirrors the engine's atomic
406 // interrupt_flag_ → the int. Two cheap 10ms-poll hops, but it
407 // keeps the C ABI int*-only (no atomic across the .so boundary).
408 int cancel_int =
409 (events_.interrupt != nullptr
410 && events_.interrupt->load(std::memory_order_acquire)) ? 1 : 0;
411
412 std::atomic<bool> observer_done(false);
413 std::thread observer;
414 if (events_.interrupt != nullptr) {
415 auto* flag = events_.interrupt;
416 observer = std::thread([&cancel_int, flag, &observer_done]() {
417 while (!observer_done.load(std::memory_order_acquire)) {
418 if (flag->load(std::memory_order_acquire)) {
419 cancel_int = 1;
420 return;
421 }
422 std::this_thread::sleep_for(std::chrono::milliseconds(10));
423 }
424 });
425 }
426
427 int rc = inference_.generate_cancellable(
428 msgs_json.c_str(), params_json.c_str(),
429 result_json, &cancel_int, inference_.backend_data);
430
431 observer_done.store(true, std::memory_order_release);
432 if (observer.joinable()) { observer.join(); }
433 return rc;
434}
435
443GenerateResult ResponseGenerator::generate_batch(LoopContext& ctx) {
444 if (inference_.generate == nullptr
445 && inference_.generate_cancellable == nullptr) {
446 logger->error("No generate function available");
447 return {"", "[]", "error"};
448 }
449
450 auto [msgs_json, params_json] = prepare_prompts(ctx, "batch");
451 char* result_json = nullptr;
452
453 // gh#81 (v2.4.2): prefer the cancellable dispatch so an interrupt
454 // is honored mid-decode (was ~60s lag pre-fix).
455 int rc = dispatch_batch_generate(msgs_json, params_json, &result_json);
456
457 GenerateResult result;
458 // gh#81 (v2.4.2): a cancelled batch is terminal, not an error —
459 // map it to "interrupted" so the engine transitions to INTERRUPTED
460 // and any partial content is preserved (mirrors the streaming
461 // resolve_stream_finish_reason policy).
462 if (rc == ENTROPIC_ERROR_CANCELLED) {
463 result.finish_reason = "interrupted";
464 if (result_json != nullptr) {
465 result.content = mcp::sanitize_utf8(result_json);
466 }
467 result.tool_calls_json = "[]";
468 logger->info("Generate cancelled (batch) after {} chars",
469 result.content.size());
470 } else if (rc == 0 && result_json != nullptr) {
471 // Issue #3 (v2.1.1): inbound boundary, batch path. See the
472 // streaming branch above for rationale; same policy applies.
473 result.content = mcp::sanitize_utf8(result_json);
474 result.finish_reason = "stop";
475 result.tool_calls_json = "[]";
476 // Fire observer once with full content so the non-streaming
477 // fallback still reaches registered observers. (2.0.6-rc16)
478 if (stream_observer_ != nullptr && !result.content.empty()) {
479 stream_observer_(result.content.data(),
480 result.content.size(),
481 stream_observer_data_);
482 }
483 } else {
484 result.finish_reason = "error";
485 logger->error("Generate failed (rc={})", rc);
486 }
487 if (result_json != nullptr && inference_.free_fn != nullptr) {
488 inference_.free_fn(result_json);
489 }
490 logger->info("Generate complete (batch): finish={}, {} chars",
491 result.finish_reason, result.content.size());
492 return result;
493}
494
509std::string ResponseGenerator::handle_pause(
510 LoopContext& ctx,
511 const std::string& partial) {
512 ctx.state = AgentState::PAUSED;
513 if (callbacks_.on_state_change != nullptr) {
514 callbacks_.on_state_change(
515 static_cast<int>(AgentState::PAUSED),
516 callbacks_.user_data);
517 }
518 // gh#40 fallout (v2.1.10): persistent slot fires alongside the
519 // legacy on_state_change so consumers see PAUSED during
520 // streaming runs where the legacy callbacks_ struct has been
521 // overwritten by run_streaming's set_callbacks() shuffle.
522 if (state_observer_ != nullptr) {
523 state_observer_(static_cast<int>(AgentState::PAUSED),
524 state_observer_data_);
525 }
526
527 char* injection = nullptr;
528 if (callbacks_.on_pause_prompt != nullptr) {
529 callbacks_.on_pause_prompt(partial.c_str(), &injection,
530 callbacks_.user_data);
531 }
532
533 if (injection == nullptr) {
534 if (events_.interrupt != nullptr) {
535 events_.interrupt->store(true);
536 }
537 return partial;
538 }
539
540 std::string inj(injection);
541 if (inj.empty()) {
542 ctx.state = AgentState::EXECUTING;
543 return partial;
544 }
545
546 // Injection provided: append partial + injection to messages
547 if (!partial.empty()) {
548 Message partial_msg;
549 partial_msg.role = "assistant";
550 partial_msg.content = partial + "\n\n[Generation paused by user]";
551 ctx.messages.push_back(std::move(partial_msg));
552 }
553 Message inject_msg;
554 inject_msg.role = "user";
555 inject_msg.content = "[User interjection]: " + inj
556 + "\n\nPlease continue with this in mind.";
557 ctx.messages.push_back(std::move(inject_msg));
558
559 ctx.state = AgentState::EXECUTING;
560 return "";
561}
562
575static void json_escape_into(const std::string& s, std::string& out) {
576 for (char c : s) {
577 switch (c) {
578 case '"': out += "\\\""; break;
579 case '\\': out += "\\\\"; break;
580 case '\n': out += "\\n"; break;
581 case '\r': out += "\\r"; break;
582 case '\t': out += "\\t"; break;
583 default: out += c; break;
584 }
585 }
586}
587
598 const std::vector<ContentPart>& parts, std::string& out) {
599 out += '[';
600 for (size_t i = 0; i < parts.size(); ++i) {
601 if (i > 0) { out += ','; }
602 if (parts[i].type == ContentPartType::IMAGE) {
603 out += R"({"type":"image","path":")";
604 json_escape_into(parts[i].image_path, out);
605 out += R"(","url":")";
606 json_escape_into(parts[i].image_url, out);
607 out += R"("})";
608 } else {
609 out += R"({"type":"text","text":")";
610 json_escape_into(parts[i].text, out);
611 out += R"("})";
612 }
613 }
614 out += ']';
615}
616
631std::string ResponseGenerator::serialize_messages(
632 const std::vector<Message>& messages) {
633 std::string json = "[";
634 for (size_t i = 0; i < messages.size(); ++i) {
635 if (i > 0) { json += ','; }
636 json += "{\"role\":\"" + messages[i].role + "\",\"content\":";
637 if (messages[i].content_parts.empty()) {
638 json += '"';
639 json_escape_into(messages[i].content, json);
640 json += '"';
641 } else {
642 serialize_content_parts(messages[i].content_parts, json);
643 }
644 json += '}';
645 }
646 json += ']';
647 return json;
648}
649
664std::string ResponseGenerator::build_params_json(
665 const std::string& tier) {
666 nlohmann::json j = nlohmann::json::object();
667 if (!tier.empty()) { j["tier"] = tier; }
668
669 if (inference_.get_tool_prompt != nullptr) {
670 char* tools = nullptr;
671 int rc = inference_.get_tool_prompt(
672 tier.c_str(), &tools, inference_.tool_prompt_data);
673 if (rc == 0 && tools != nullptr) {
674 j["tools"] = std::string(tools);
675 if (inference_.free_fn) { inference_.free_fn(tools); }
676 }
677 }
678 return j.dump();
679}
680
702std::vector<Message> ResponseGenerator::inject_engine_state_reminder(
703 const std::vector<Message>& messages,
704 const LoopContext& ctx) {
705 int max_iter = ctx.effective_max_iterations >= 0
706 ? ctx.effective_max_iterations
707 : loop_config_.max_iterations;
708 std::string reminder = "[engine] iteration "
709 + std::to_string(ctx.metrics.iterations)
710 + "/" + std::to_string(max_iter)
711 + ", tool calls so far: "
712 + std::to_string(ctx.metrics.tool_calls) + ".";
713
714 // Demo ask #2 (v2.1.0): if the previous turn was validator-rejected,
715 // surface the reason so the model knows WHY it's being asked again.
716 // Engine clears pending_validation_feedback after this turn — the
717 // line is one-shot.
718 if (!ctx.pending_validation_feedback.empty()) {
719 reminder += "\n[engine] previous turn rejected: "
720 + ctx.pending_validation_feedback;
721 }
722 // Demo ask #5 (v2.1.0): anti-spiral primitive. ToolExecutor
723 // populated this when consecutive_same_tool_calls hit
724 // max_consecutive_same_tool. Same one-shot lifecycle as the
725 // validation feedback above; engine clears after this turn.
726 if (!ctx.pending_anti_spiral_warning.empty()) {
727 reminder += "\n[engine] anti-spiral: "
728 + ctx.pending_anti_spiral_warning;
729 }
730
731 auto result = messages;
732 Message reminder_msg;
733 reminder_msg.role = "user";
734 reminder_msg.content = std::move(reminder);
735 result.push_back(std::move(reminder_msg));
736 return result;
737}
738
739} // namespace entropic
GenerateResult generate_response(LoopContext &ctx)
Generate model response, routing tier first if needed.
ResponseGenerator(const InferenceInterface &inference, const LoopConfig &loop_config, EngineCallbacks &callbacks, GenerationEvents events)
Construct a response generator.
bool is_response_complete(const std::string &content, const std::string &tool_calls_json)
Check if the last response indicates completion.
Error types for cross-.so error reporting.
@ ENTROPIC_ERROR_CANCELLED
Operation cancelled via cancel token.
Definition error.h:50
@ ENTROPIC_HOOK_ON_STREAM_TOKEN
2: Each streaming token emitted
Definition hooks.h:43
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::unordered_map< std::string, size_t > s_tier_system_hash
Per-tier system prompt hash for diff detection across delegations.
static void log_prompt(const std::vector< Message > &messages, const std::string &tier)
Log the full assembled prompt (all messages, no truncation).
static void serialize_content_parts(const std::vector< ContentPart > &parts, std::string &out)
Serialize a single multimodal content_parts array (gh#37, v2.1.8).
static void json_escape_into(const std::string &s, std::string &out)
Serialize messages to JSON for inference interface.
static std::string resolve_stream_finish_reason(int rc, size_t content_size)
Resolve a stream's finish_reason from rc + content size.
static void stream_token_callback(const char *token, size_t len, void *user_data)
Token callback for streaming generation.
Response generation subsystem for the agentic loop.
Callback function pointer types for engine events.
void(* on_tier_selected)(const char *tier, void *ud)
Tier routing result.
void * user_data
Opaque pointer passed to all callbacks.
Result of a generate_response call.
Atomic flags for interrupt/pause signaling.
std::atomic< bool > * interrupt
Hard interrupt flag.
Configuration for the agentic loop.
bool stream_output
Stream vs batch generation.
bool speculative_enabled
gh#110 (v2.9.6): mirrors inference.speculative.enabled from config, plumbed through since core....
Mutable state carried through the agentic loop.
std::vector< Message > messages
Conversation history.
std::string locked_tier
Tier locked for this loop ("" = none)
Context passed to the streaming token callback.
const HookInterface * hooks
Hook dispatch (v1.9.1)
void * observer_data
Observer user_data.
std::string content
Accumulated content.
void(* observer)(const char *, size_t, void *)
Global observer — fires on every token alongside callbacks->on_stream_chunk.
EngineCallbacks * callbacks
Callback reference.
bool interrupted
Set when interrupt detected.
int token_index
Token counter (v1.9.1)
GenerationEvents * events
Event flags.
int * cancel_flag
Pointer to the backend's cancel flag (gh#20, v2.1.5).
UTF-8 validation + replacement at every system boundary where bytes change ownership.