Entropic 2.9.4
Local-first agentic inference engine
Loading...
Searching...
No Matches
adapter_base.cpp
Go to the documentation of this file.
1// SPDX-License-Identifier: Apache-2.0
14
15#include <nlohmann/json.hpp>
16
17#include <algorithm>
18#include <atomic>
19#include <cstdint>
20#include <regex>
21#include <sstream>
22#include <unordered_set>
23
24namespace entropic {
25
26namespace {
27auto logger = entropic::log::get("inference.adapter");
28
30constexpr const char* TOOL_RESULT_SUFFIX =
31 "Continue. Batch multiple tool calls in one response when possible.";
32
39std::string generate_uuid() {
40 // Simple counter-based ID for now. Replace with proper UUID if needed.
41 static std::atomic<uint64_t> counter{0};
42 return "tc-" + std::to_string(counter.fetch_add(1, std::memory_order_relaxed));
43}
44
60std::string tool_name_from_json(const nlohmann::json& j) {
61 for (const char* key : {"name", "tool_name", "function", "function_name"}) {
62 if (j.contains(key) && j[key].is_string()) {
63 return j[key].get<std::string>();
64 }
65 }
66 return "";
67}
68
82std::optional<ToolCall> tool_call_from_json(const nlohmann::json& j) {
83 std::string name = tool_name_from_json(j);
84 if (name.empty()) { return std::nullopt; }
85 ToolCall tc;
86 tc.id = generate_uuid();
87 tc.name = std::move(name);
88 auto args = j.value("arguments", j.value("parameters", nlohmann::json::object()));
89 for (auto& [k, v] : args.items()) { tc.arguments[k] = v.dump(); }
90 return tc;
91}
92
93} // anonymous namespace
94
95// ── Constructor ────────────────────────────────────────────
96
103ChatAdapter::ChatAdapter(std::string tier_name, std::string identity_prompt)
104 : tier_name_(std::move(tier_name))
105 , identity_prompt_(std::move(identity_prompt))
106{
107}
108
109// ── System prompt assembly ─────────────────────────────────
110
120 const std::string& base_prompt,
121 const std::vector<std::string>& tool_jsons) const
122{
123 std::string prompt = identity_prompt_;
124
125 if (!base_prompt.empty()) {
126 prompt += "\n\n" + base_prompt;
127 }
128
129 if (!tool_jsons.empty()) {
130 // Extract tool prefixes for later parsing
131 for (const auto& json_str : tool_jsons) {
132 try {
133 auto j = nlohmann::json::parse(json_str);
134 std::string name = j.value("name", "");
135 auto dot = name.find('.');
136 if (dot != std::string::npos) {
137 tool_prefixes_.insert(name.substr(0, dot));
138 }
139 } catch (...) {
140 // Skip malformed tool JSON
141 }
142 }
143 prompt += "\n\n" + format_tools(tool_jsons);
144 }
145
146 return prompt;
147}
148
149// ── Tool result formatting ─────────────────────────────────
150
160 const ToolCall& tool_call,
161 const std::string& result) const
162{
163 Message msg;
164 msg.role = "user";
165 msg.content = "Tool `" + tool_call.name + "` returned:\n\n" +
166 result + "\n\n" + TOOL_RESULT_SUFFIX;
167 return msg;
168}
169
170// ── Response completeness ──────────────────────────────────
171
184 const std::string& content,
185 const std::vector<ToolCall>& tool_calls) const
186{
187 // Has tool calls → not complete (needs execution)
188 if (!tool_calls.empty()) {
189 return false;
190 }
191
192 // Unclosed think block → still thinking
193 if (content.find("<think>") != std::string::npos &&
194 content.find("</think>") == std::string::npos)
195 {
196 return false;
197 }
198
199 // Strip think blocks and check for real content
200 std::string stripped = strip_think_blocks(content);
201 return !stripped.empty();
202}
203
204// ── Tagged tool call parsing ───────────────────────────────
205
214 const std::string& content) const
215{
216 std::vector<ToolCall> calls;
217 // gh#65 (v2.3.3): accept asymmetric open tags. Gemma 4 emits
218 // `<|tool_call>` (pipe-prefixed open, plain close) — the special
219 // token `<|tool_call|>` decodes through llama.cpp's current pin
220 // as `<|tool_call>` (trailing `|>` lost). Pre-v2.3.3 the regex
221 // required a plain `<tool_call>` open, so Gemma 4's actual output
222 // produced 0 tool calls and the engine looped on the retry banner.
223 //
224 // gh#69 (v2.3.8): add `<|im_start|>tool_call` as a fourth open
225 // variant. Gemma 4 (E2B + E4B) emits its tool calls inside a
226 // ChatML-style channel whose opening header is `<|im_start|>tool_call`
227 // but whose close is the plain `</tool_call>` — an asymmetric pair
228 // the prior three alternatives didn't cover, so both Gemma 4 sizes
229 // scored 0/6 completion (agent loop spiralled to the iteration cap).
230 //
231 // Open alternatives: `<tool_call>`, `<|tool_call>`, `<|tool_call|>`,
232 // `<|im_start|>tool_call`. Close tag stays `</tool_call>` — that's
233 // what the consumer's transcripts consistently show.
234 std::regex pattern(
235 R"((?:<tool_call>|<\|tool_call\|?>|<\|im_start\|>tool_call)\s*)"
236 R"(([\s\S]*?)\s*</tool_call>)");
237
238 auto begin = std::sregex_iterator(content.begin(), content.end(), pattern);
239 auto end = std::sregex_iterator();
240
241 for (auto it = begin; it != end; ++it) {
242 std::string json_str = (*it)[1].str();
243 auto parsed = parse_single_tool_call(json_str);
244 if (parsed) {
245 calls.push_back(*parsed);
246 logger->info("Parsed tagged tool call: {}", parsed->name);
247 } else {
248 // gh#65: when the regex matches but parse_single_tool_call
249 // returns nullopt, the JSON payload was malformed in a way
250 // try_recover_json could not fix. Log the offending text so
251 // future investigations have something to grep, instead of
252 // silently producing zero tool calls.
253 logger->warn(
254 "Tagged tool_call matched but JSON failed to parse: {}",
255 json_str);
256 }
257 }
258 // gh#65/gh#69: model emitted tool_call markup but no regex match.
259 // Catches plain `<tool_call>`, pipe-prefixed `<|tool_call`, and the
260 // Gemma 4 channel header `<|im_start|>tool_call` substrings — if
261 // none matched the full pattern, surface the raw content's length so
262 // the consumer can attach it for triage instead of seeing a silent
263 // "tool_calls: 0".
264 if (calls.empty()
265 && (content.find("<tool_call>") != std::string::npos
266 || content.find("<|tool_call") != std::string::npos
267 || content.find("<|im_start|>tool_call") != std::string::npos)) {
268 logger->warn(
269 "Content contains tool_call markup but no tagged calls "
270 "were extracted — possible tag/encoding mismatch. "
271 "Raw content length={}", content.size());
272 }
273 return calls;
274}
275
276// ── Bare JSON parsing ──────────────────────────────────────
277
286 const std::string& content) const
287{
288 std::vector<ToolCall> calls;
289 std::istringstream stream(content);
290 std::string line;
291
292 while (std::getline(stream, line)) {
293 // Trim
294 size_t start = line.find_first_not_of(" \t");
295 if (start == std::string::npos) continue;
296 std::string_view stripped(line.data() + start, line.size() - start);
297
298 // Gate on a name key under any accepted alias (gh#71-phase-2):
299 // a bare `{"name":...}` or `{"tool_name":...}` line is a call.
300 if (stripped.front() != '{'
301 || (stripped.find("name") == std::string_view::npos)) {
302 continue;
303 }
304
305 try {
306 auto j = nlohmann::json::parse(stripped);
307 if (auto tc = tool_call_from_json(j)) {
308 calls.push_back(*tc);
309 }
310 } catch (...) {
311 // Skip unparseable lines
312 }
313 }
314 return calls;
315}
316
317// ── gh#88 action-envelope recovery (free functions) ─────────
318
336static std::optional<ToolCall> action_envelope_to_call(
337 const nlohmann::json& j) {
338 if (auto tc = tool_call_from_json(j)) { return tc; }
339 // gh#88: meta-tools whose result `action` verb equals their tool name.
340 // TodoTool (add/update/remove) and free-text actions are excluded.
341 static const std::unordered_set<std::string> kMetaActions = {
342 "delegate", "pipeline", "complete",
343 "phase_change", "prune_context", "resume_delegation"};
344 std::string action;
345 if (j.contains("action") && j["action"].is_string()) {
346 action = j["action"].get<std::string>();
347 }
348 if (kMetaActions.find(action) == kMetaActions.end()) {
349 return std::nullopt;
350 }
351 ToolCall tc;
352 tc.id = generate_uuid();
353 tc.name = "entropic." + action;
354 nlohmann::json args = j;
355 args.erase("action");
356 tc.arguments_json = args.dump();
357 for (auto& [k, v] : args.items()) { tc.arguments[k] = v.dump(); }
358 return tc;
359}
360
368std::vector<ToolCall> recover_action_envelope_calls(const std::string& raw) {
369 std::vector<ToolCall> calls;
370 std::istringstream stream(raw);
371 std::string line;
372 while (std::getline(stream, line)) {
373 size_t start = line.find_first_not_of(" \t");
374 if (start == std::string::npos || line[start] != '{') { continue; }
375 auto j = nlohmann::json::parse(line.substr(start), nullptr, false);
376 if (!j.is_object()) { continue; }
377 if (auto tc = action_envelope_to_call(j)) {
378 calls.push_back(std::move(*tc));
379 }
380 }
381 return calls;
382}
383
391void apply_action_envelope_recovery(std::vector<ToolCall>& calls,
392 const std::string& raw) {
393 if (!calls.empty()) { return; }
394 auto recovered = recover_action_envelope_calls(raw);
395 if (recovered.empty()) { return; }
396 logger->warn(
397 "gh#88: PEG_GEMMA4 parsed 0 tool calls; recovered {} from a "
398 "bare-JSON {{\"action\":...}} envelope (possible context priming)",
399 recovered.size());
400 calls = std::move(recovered);
401}
402
403// ── gh#90 string-typed-arg coercion (free functions) ────────
404
413static std::unordered_set<std::string> tool_string_props(
414 const nlohmann::json& tools, const std::string& name) {
415 std::unordered_set<std::string> props;
416 for (const auto& t : tools) {
417 if (!t.is_object() || t.value("name", std::string()) != name) {
418 continue;
419 }
420 auto schema = t.value("inputSchema", nlohmann::json::object());
421 auto properties = schema.value("properties", nlohmann::json::object());
422 for (auto it = properties.begin(); it != properties.end(); ++it) {
423 if (it->is_object()
424 && it->value("type", std::string()) == "string") {
425 props.insert(it.key());
426 }
427 }
428 break;
429 }
430 return props;
431}
432
440static void coerce_call_string_args(ToolCall& tc, const nlohmann::json& tools) {
441 auto props = tool_string_props(tools, tc.name);
442 if (props.empty()) { return; }
443 auto args = nlohmann::json::parse(tc.arguments_json, nullptr, false);
444 if (!args.is_object()) { return; }
445 bool changed = false;
446 for (const auto& key : props) {
447 auto it = args.find(key);
448 if (it != args.end() && it->is_number()) {
449 *it = it->dump(); // JSON number 3 → JSON string "3"
450 changed = true;
451 }
452 }
453 if (!changed) { return; }
454 tc.arguments_json = args.dump();
455 for (auto it = args.begin(); it != args.end(); ++it) {
456 tc.arguments[it.key()] =
457 it->is_string() ? it->get<std::string>() : it->dump();
458 }
459}
460
468void coerce_string_typed_args(std::vector<ToolCall>& calls,
469 const std::string& tools_json) {
470 if (calls.empty() || tools_json.empty()) { return; }
471 auto tools = nlohmann::json::parse(tools_json, nullptr, false);
472 if (!tools.is_array()) { return; }
473 for (auto& tc : calls) { coerce_call_string_args(tc, tools); }
474}
475
476// ── Think block handling ───────────────────────────────────
477
485std::string ChatAdapter::extract_thinking(const std::string& content) const {
486 std::string result;
487 std::regex pattern(R"(<think>([\s\S]*?)</think>)");
488
489 auto begin = std::sregex_iterator(content.begin(), content.end(), pattern);
490 auto end = std::sregex_iterator();
491
492 for (auto it = begin; it != end; ++it) {
493 if (!result.empty()) result += "\n";
494 result += (*it)[1].str();
495 }
496 return result;
497}
498
506std::string ChatAdapter::strip_think_blocks(const std::string& content) const {
507 std::regex pattern(R"(<think>[\s\S]*?</think>)");
508 std::string result = std::regex_replace(content, pattern, "");
509
510 // Trim
511 size_t start = result.find_first_not_of(" \t\n\r");
512 if (start == std::string::npos) return "";
513 size_t end_pos = result.find_last_not_of(" \t\n\r");
514 return result.substr(start, end_pos - start + 1);
515}
516
517// ── JSON recovery ──────────────────────────────────────────
518
526static std::optional<ToolCall> parse_recovered_tool_call(
527 const std::string& fixed) {
528 auto j = nlohmann::json::parse(fixed);
529 return tool_call_from_json(j);
530}
531
539static std::optional<ToolCall> regex_recovered_tool_call(
540 const std::string& json_str) {
541 std::regex name_pattern(R"re("name"\s*:\s*"([^"]+)")re");
542 std::smatch match;
543 if (!std::regex_search(json_str, match, name_pattern)) {
544 return std::nullopt;
545 }
546 ToolCall tc;
547 tc.id = generate_uuid();
548 tc.name = match[1].str();
549 return tc;
550}
551
562std::optional<ToolCall> ChatAdapter::try_recover_json(
563 const std::string& json_str) const
564{
565 // Fix trailing commas and single quotes
566 std::string fixed = std::regex_replace(json_str, std::regex(R"(,\s*\})"), "}");
567 fixed = std::regex_replace(fixed, std::regex(R"(,\s*\])"), "]");
568 std::replace(fixed.begin(), fixed.end(), '\'', '"');
569
570 logger->info("JSON recovery attempt: {} chars", json_str.size());
571 try {
572 if (auto tc = parse_recovered_tool_call(fixed)) { return tc; }
573 } catch (...) {
574 return regex_recovered_tool_call(json_str);
575 }
576 return std::nullopt;
577}
578
579// ── Tool formatting (default) ──────────────────────────────
580
589 const std::vector<std::string>& tool_jsons) const
590{
591 std::ostringstream out;
592 out << "## Tools\n\n"
593 << "Call tools with: `<tool_call>{\"name\": \"tool.name\", \"arguments\": {...}}</tool_call>`\n"
594 << "Batch independent calls in one response with multiple `<tool_call>` blocks.\n\n";
595
596 for (const auto& json_str : tool_jsons) {
597 try {
598 auto j = nlohmann::json::parse(json_str);
599 out << "### " << j.value("name", "unknown") << "\n"
600 << j.value("description", "No description") << "\n\n"
601 << "Schema:\n```json\n"
602 << j.value("inputSchema", nlohmann::json::object()).dump(2)
603 << "\n```\n\n";
604 } catch (...) {
605 out << "### (malformed tool definition)\n\n";
606 }
607 }
608 return out.str();
609}
610
611// ── Internal helper ────────────────────────────────────────
612
621 const std::string& json_str) const
622{
623 try {
624 auto j = nlohmann::json::parse(json_str);
625 if (auto tc = tool_call_from_json(j)) {
626 return tc;
627 }
628 } catch (...) {
629 return try_recover_json(json_str);
630 }
631 return std::nullopt;
632}
633
634// ── Vision / multimodal (v1.9.11) ──────────────────────────
635
645 const std::string& base_system,
646 bool /*has_vision*/) const {
647 return base_system;
648}
649
658 const std::vector<ContentPart>& parts) const {
659 nlohmann::json arr = nlohmann::json::array();
660 for (const auto& part : parts) {
661 nlohmann::json obj;
662 if (part.type == ContentPartType::TEXT) {
663 obj["type"] = "text";
664 obj["text"] = part.text;
665 } else {
666 obj["type"] = "image";
667 if (!part.image_path.empty()) {
668 obj["path"] = part.image_path;
669 }
670 if (!part.image_url.empty()) {
671 obj["url"] = part.image_url;
672 }
673 }
674 arr.push_back(std::move(obj));
675 }
676 return arr.dump();
677}
678
679} // namespace entropic
ChatAdapter concrete base class.
std::string format_system_prompt(const std::string &base_prompt, const std::vector< std::string > &tool_jsons) const
Assemble system prompt: identity + context + tools.
ChatAdapter(std::string tier_name, std::string identity_prompt)
Construct adapter with tier identity.
std::unordered_set< std::string > tool_prefixes_
Known tool prefixes.
std::optional< ToolCall > try_recover_json(const std::string &json_str) const
Attempt JSON recovery on malformed tool call string.
virtual std::string format_content_parts(const std::vector< ContentPart > &parts) const
Convert multimodal content parts to adapter-specific format.
virtual std::string format_system_with_vision(const std::string &base_system, bool has_vision) const
Format system prompt with optional vision context.
std::optional< ToolCall > parse_single_tool_call(const std::string &json_str) const
Parse a single JSON tool call string.
std::vector< ToolCall > parse_tagged_tool_calls(const std::string &content) const
Parse <tool_call>JSON</tool_call> tagged blocks.
std::vector< ToolCall > parse_bare_json_tool_calls(const std::string &content) const
Parse bare JSON lines containing "name" key.
virtual std::string format_tools(const std::vector< std::string > &tool_jsons) const
Format tool definitions for injection into system prompt.
bool is_response_complete(const std::string &content, const std::vector< ToolCall > &tool_calls) const
Check if response represents task completion.
std::string strip_think_blocks(const std::string &content) const
Strip all <think>...</think> blocks from content.
std::string identity_prompt_
Assembled identity prompt.
virtual Message format_tool_result(const ToolCall &tool_call, const std::string &result) const
Format a tool result as a user message.
std::string extract_thinking(const std::string &content) const
Extract <think>...</think> content.
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).
@ TEXT
Plain text content.
static std::optional< ToolCall > parse_recovered_tool_call(const std::string &fixed)
Parse a brace/quote-fixed JSON string into a ToolCall.
void coerce_string_typed_args(std::vector< ToolCall > &calls, const std::string &tools_json)
gh#90: coerce numeric scalars back to strings for string-typed tool parameters.
std::vector< ToolCall > recover_action_envelope_calls(const std::string &raw)
gh#88: recover tool calls a gemma model parroted as bare-JSON.
void apply_action_envelope_recovery(std::vector< ToolCall > &calls, const std::string &raw)
gh#88: substitute recovered bare-JSON calls when a reliable (PEG_GEMMA4 / gemma) parse produced none;...
static std::optional< ToolCall > action_envelope_to_call(const nlohmann::json &j)
Map one parsed JSON object to a recovered ToolCall.
static void coerce_call_string_args(ToolCall &tc, const nlohmann::json &tools)
Coerce one call's numeric args to strings per the tool schema.
std::string generate_uuid()
Generate a UUID v4 string.
Definition backend.cpp:840
static std::optional< ToolCall > regex_recovered_tool_call(const std::string &json_str)
Last-ditch recovery: pull a tool name out via regex.
static std::unordered_set< std::string > tool_string_props(const nlohmann::json &tools, const std::string &name)
String-typed property names for a tool in the staged MCP defs.
A message in a conversation.
Definition message.h:35
std::string content
Message text content (always populated)
Definition message.h:37
std::string role
Message role.
Definition message.h:36
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