Files
hermes-agent/proxy-patches/openai-to-cli.js
T
ARIA f8d71e00bb fix(proxy): Claude anweisen, Tool-Verbindungsstatus nicht zu halluzinieren
Live-Test (spotify_test_connection.py) bestaetigte: Login, Toolset und
API-Erreichbarkeit sind alle OK. Trotzdem antwortete Claude im Chat
'Tools in Werkzeugliste beschrieben, aber nicht verbunden/aktiv' - eine
frei erfundene Einschraenkung, die nirgends im injizierten System-Prompt
stand (der beschreibt nur Namen/Schema der Tools, nichts zum Verbindungs-
status). Klassisches Modell-Hedging bei Unsicherheit ueber Backend-Status.

Ergaenzt im Tool-Block: expliziter Hinweis dass alle gelisteten Tools
bereits authentifiziert/einsatzbereit sind und ohne Rueckfrage zur
Verbindung genutzt werden sollen, Fehler nur anhand echter Tool-Antworten
(z.B. 401/403) melden.
2026-07-20 00:27:22 +00:00

266 lines
11 KiB
JavaScript

/**
* Hermes-Proxy openai-to-cli Adapter.
*
* 1:1 uebernommen aus dem ARIA-Proxy (/root/ARIA-AGENT/proxy-patches/openai-to-cli.js) —
* generisch, kein ARIA-spezifischer Code drin. Erweitert die npm-Version von
* claude-max-api-proxy um:
* - Multimodal-Content (Array von text-Parts) wird zu String reduziert.
* - Wenn die Anfrage ein `tools`-Feld enthaelt: die Tool-Definitionen
* werden in den System-Prompt als Anweisung injiziert, das
* <tool_call name="...">{...}</tool_call> Format zu benutzen statt
* freiem Text.
* - Wenn Messages role=tool enthalten: deren Inhalt wird als
* <tool_result tool_call_id="...">…</tool_result> ins Prompt-Fragment
* eingewoben damit Claude den Loop-Step bekommt.
*
* Wird zur Container-Startzeit ueber die npm-Version geschrieben
* (siehe docker-compose.yml hermes-proxy-Block).
*/
const MODEL_MAP = {
"claude-opus-4": "opus",
"claude-sonnet-4": "sonnet",
"claude-haiku-4": "haiku",
"claude-code-cli/claude-opus-4": "opus",
"claude-code-cli/claude-sonnet-4": "sonnet",
"claude-code-cli/claude-haiku-4": "haiku",
"opus": "opus",
"sonnet": "sonnet",
"haiku": "haiku",
};
export function extractModel(model) {
if (MODEL_MAP[model]) return MODEL_MAP[model];
const stripped = (model || "").replace(/^claude-code-cli\//, "");
if (MODEL_MAP[stripped]) return MODEL_MAP[stripped];
return "opus";
}
/** Multimodal: content kann String oder Array von Parts sein. */
function _text(c) {
if (typeof c === "string") return c;
if (Array.isArray(c)) {
return c
.filter((b) => b && b.type === "text")
.map((b) => b.text || "")
.join("");
}
return String(c == null ? "" : c);
}
/**
* Baut den Tool-Use-Block fuer den System-Prompt.
* Anweisung: Claude soll <tool_call name="X">{json args}</tool_call>
* ausgeben statt das Tool intern via Bash zu simulieren.
*/
function _toolsBlock(tools) {
if (!Array.isArray(tools) || tools.length === 0) return "";
const lines = [];
lines.push("# Verfuegbare Tools");
lines.push("");
lines.push(
"Du hast neben deinen eigenen internen Tools (Bash, Read, etc.) auch " +
"diese externen Tools, die im Backend-System angesiedelt sind. " +
"Sie sind die EINZIGE Moeglichkeit diese Aktionen auszuloesen. " +
"Simuliere sie NICHT mit Bash/sleep — rufe sie sauber auf:"
);
lines.push("");
lines.push(
"WICHTIG: Alle unten gelisteten Tools sind bereits authentifiziert, " +
"eingeloggt und einsatzbereit — der Nutzer hat das vorab eingerichtet. " +
"Du hast KEINE Moeglichkeit und KEINEN Grund, den Verbindungs- oder " +
"Login-Status zu pruefen oder in Frage zu stellen — es gibt dafuer kein " +
"separates Status-Tool und keinen Hinweis im Prompt, der etwas anderes " +
"sagt. Behaupte NIEMALS von dir aus, ein Tool sei \"nicht verbunden\", " +
"\"nicht aktiv\" oder \"nicht autorisiert\" — das ist erfunden, wenn es " +
"nicht explizit hier steht. Ruf das passende Tool bei einer passenden " +
"Anfrage EINFACH DIREKT auf, ohne Rueckfrage zur Verbindung. Nur bei " +
"einem echten Fehler in der Tool-ANTWORT (z.B. HTTP 401/403) darfst du " +
"das als Problem melden."
);
lines.push("");
for (const t of tools) {
if (!t || t.type !== "function" || !t.function) continue;
const fn = t.function;
const name = fn.name || "";
const desc = fn.description || "";
const params = fn.parameters || {};
lines.push(`## ${name}`);
if (desc) lines.push(desc);
try {
lines.push("Schema: " + JSON.stringify(params));
} catch (_) {
lines.push("Schema: (nicht serialisierbar)");
}
lines.push("");
}
lines.push("# Tool-Call-Format");
lines.push("");
lines.push(
"Wenn du eines der OBIGEN externen Tools aufrufen willst, antworte " +
"**ausschliesslich** mit einem oder mehreren Bloecken in genau dieser Form, " +
"JEDER fuer sich auf einer eigenen Zeile:"
);
lines.push("");
lines.push('<tool_call name="TOOL_NAME">{"arg1":"value","arg2":123}</tool_call>');
lines.push("");
lines.push(
"Regeln: (1) Innerhalb des Blocks steht NUR gueltiges JSON mit den Argumenten. " +
"(2) Kein Text drumherum. (3) Keine Code-Fences, kein Markdown. " +
"(4) Mehrere Tool-Calls = mehrere Bloecke untereinander. " +
"(5) Nach den Bloecken aufhoeren — der Server fuehrt die Tools aus und " +
"schickt dir die Ergebnisse fuer den naechsten Turn. " +
"(6) Wenn KEIN externes Tool noetig ist, antworte normal als Text."
);
return lines.join("\n");
}
export function messagesToPrompt(messages, tools) {
const parts = [];
const toolsBlock = _toolsBlock(tools);
if (toolsBlock) {
parts.push(`<system>\n${toolsBlock}\n</system>\n`);
}
for (const msg of messages) {
if (!msg) continue;
switch (msg.role) {
case "system":
parts.push(`<system>\n${_text(msg.content)}\n</system>\n`);
break;
case "user":
parts.push(_text(msg.content));
break;
case "assistant": {
const txt = _text(msg.content);
const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
const tcParts = tcs.map((tc) => {
const name = tc?.function?.name || tc?.name || "";
let args = tc?.function?.arguments ?? tc?.arguments ?? "{}";
if (typeof args !== "string") {
try { args = JSON.stringify(args); } catch (_) { args = "{}"; }
}
return `<tool_call name="${name}">${args}</tool_call>`;
}).join("\n");
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
break;
}
case "tool": {
const name = msg.name || "";
const id = msg.tool_call_id || "";
parts.push(
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\n`
);
break;
}
}
}
return parts.join("\n").trim();
}
/**
* Extrahiert NUR den System-Anteil (System-Messages + Tool-Use-Block) als
* rohen Text — OHNE <system>-Tags. Fuer den ECHTEN System-Prompt-Kanal der
* Claude-CLI (--system-prompt, VOLLER Replace — nicht --append). Damit ist
* die Ziel-Persona/Rolle DIE Identitaet des Modells und nicht ein Anhaengsel
* hinter Claude Codes eigener "You are Claude Code"-Identitaet (die bei
* duennem Kontext sonst gewinnt). Der Output muss deshalb SELBSTTRAGEND sein.
* Reihenfolge: erst der Tool-Use-Block (Format-Anweisung), dann die
* System-Messages in Original-Reihenfolge.
*/
export function extractSystemPrompt(messages, tools) {
const chunks = [];
const toolsBlock = _toolsBlock(tools);
if (toolsBlock) chunks.push(toolsBlock);
for (const msg of messages || []) {
if (msg && msg.role === "system") {
const t = _text(msg.content).trim();
if (t) chunks.push(t);
}
}
return chunks.join("\n\n").trim();
}
/**
* Wie messagesToPrompt, aber OHNE System-Messages und OHNE Tool-Block — nur der
* eigentliche Verlauf (user/assistant/tool). Fuer den Modus, in dem der
* System-Prompt ueber --system-prompt separat zugestellt wird.
*/
export function conversationToPrompt(messages) {
const parts = [];
for (const msg of messages || []) {
if (!msg) continue;
switch (msg.role) {
case "system":
break; // geht ueber --system-prompt
case "user":
parts.push(_text(msg.content));
break;
case "assistant": {
const txt = _text(msg.content);
const tcs = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
const tcParts = tcs.map((tc) => {
const name = tc?.function?.name || tc?.name || "";
let args = tc?.function?.arguments ?? tc?.arguments ?? "{}";
if (typeof args !== "string") {
try { args = JSON.stringify(args); } catch (_) { args = "{}"; }
}
return `<tool_call name="${name}">${args}</tool_call>`;
}).join("\n");
const combined = [txt, tcParts].filter(Boolean).join("\n").trim();
if (combined) parts.push(`<previous_response>\n${combined}\n</previous_response>\n`);
break;
}
case "tool": {
const name = msg.name || "";
const id = msg.tool_call_id || "";
parts.push(
`<tool_result tool_call_id="${id}" name="${name}">\n${_text(msg.content)}\n</tool_result>\n`
);
break;
}
}
}
return parts.join("\n").trim();
}
export function openaiToCli(request) {
// Persona/System + Tool-Block gehen ueber den ECHTEN System-Prompt-Kanal
// (--system-prompt = VOLLER Replace, siehe manager.js sed-Patch in
// docker-compose.yml). Der Prompt enthaelt nur noch den Gespraechsverlauf.
// systemPrompt ist immer ein String (nie undefined) — bei --system-prompt
// darf er nicht leer sein, sonst laeuft das Modell ohne System-Prompt.
const systemPrompt = extractSystemPrompt(request.messages, request.tools);
// TEMP-DEBUG (20.07.2026, Root-Cause-Suche "Claude kennt Hermes-Tools nicht"):
// Sichtbar per `docker logs hermes-proxy`. Zeigt schwarz auf weiss, ob
// Hermes ueberhaupt ein `tools`-Feld + system-Messages mitschickt, und wie
// lang der daraus gebaute systemPrompt ist. Wenn systemPromptLen sehr klein
// ist (z.B. 0) UND toolsCount 0 ist: Hermes schickt schlicht nichts mit,
// das ist dann kein Proxy-Bug sondern ein Hermes-Konfig-Thema (Toolset
// nicht aktiv / Persona-Prompt anders uebergeben). Wieder rausnehmen sobald
// geklaert.
try {
const roles = (request.messages || []).map((m) => m && m.role);
const toolNames = Array.isArray(request.tools)
? request.tools.map((t) => t?.function?.name).filter(Boolean)
: [];
console.error(
"[openai-to-cli][DEBUG] messageRoles=" + JSON.stringify(roles) +
" toolsCount=" + (Array.isArray(request.tools) ? request.tools.length : 0) +
" toolNames=" + JSON.stringify(toolNames) +
" systemPromptLen=" + systemPrompt.length +
" systemPromptPreview=" + JSON.stringify(systemPrompt.slice(0, 200))
);
} catch (e) {
console.error("[openai-to-cli][DEBUG] logging failed: " + e.message);
}
return {
prompt: conversationToPrompt(request.messages),
systemPrompt,
model: extractModel(request.model),
sessionId: request.user,
};
}