feat(local-llm): B1b-Plumbing — tools/tool_calls durch Adapter/Bridge/Brain-Client

Traegt OpenAI-Tool-Definitionen (tools) durch den ganzen lokalen Pfad und gibt
tool_calls zurueck:
- adapter.py: tools -> llama.cpp /v1/chat/completions (tool_choice=auto),
  message.tool_calls zurueck in llm_response.
- aria_bridge.py: _local_llm + /internal/local-llm reichen tools durch, geben
  tool_calls zurueck.
- local_llm.py: local_llm_chat akzeptiert tools, result enthaelt tool_calls.

Inert bis der Brain-Tool-Loop (naechster Schritt) tools uebergibt — Verhalten
unveraendert. Tool-Set + lokale Tool-Loop + Router-Anpassung folgen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:36:09 +02:00
co-authored by Claude Opus 4.8
parent 3a3c14fdc5
commit a58aa5594d
3 changed files with 37 additions and 13 deletions
+6 -2
View File
@@ -29,14 +29,18 @@ LOCAL_LLM_HTTP_TIMEOUT_SEC = float(os.environ.get("LOCAL_LLM_HTTP_TIMEOUT_SEC",
def local_llm_chat(messages: list, *, max_tokens: int = 512, def local_llm_chat(messages: list, *, max_tokens: int = 512,
temperature: float = 0.7, stop=None) -> dict: temperature: float = 0.7, stop=None, tools=None) -> dict:
"""Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...]. """Ein Chat-Call ans lokale LLM. messages = [{role, content}, ...].
Blockierend (urllib) — im Brain laeuft chat() ohnehin im Executor-Thread.""" tools (B1b): optionale OpenAI-Tool-Defs; das Ergebnis kann dann
result['tool_calls'] enthalten. Blockierend (urllib) — chat() laeuft
ohnehin im Executor-Thread."""
if not isinstance(messages, list) or not messages: if not isinstance(messages, list) or not messages:
return {"ok": False, "error": "messages leer/ungueltig"} return {"ok": False, "error": "messages leer/ungueltig"}
req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature} req = {"messages": messages, "max_tokens": max_tokens, "temperature": temperature}
if stop: if stop:
req["stop"] = stop req["stop"] = stop
if tools:
req["tools"] = tools
try: try:
body = json.dumps(req).encode("utf-8") body = json.dumps(req).encode("utf-8")
http_req = urllib.request.Request( http_req = urllib.request.Request(
+10 -4
View File
@@ -3367,9 +3367,10 @@ class ARIABridge:
_LLM_TIMEOUT_S = 30.0 _LLM_TIMEOUT_S = 30.0
async def _local_llm(self, messages: list, max_tokens: int = 512, async def _local_llm(self, messages: list, max_tokens: int = 512,
temperature: float = 0.7, stop=None) -> dict: temperature: float = 0.7, stop=None, tools=None) -> dict:
"""Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf """Schickt einen llm_request an den llm-adapter (Gamebox), wartet auf
llm_response. Rueckgabe: {ok, content, model, elapsedMs} oder {ok:False, error}.""" llm_response. tools (B1b) werden durchgereicht; tool_calls kommen zurueck.
Rueckgabe: {ok, content, tool_calls, model, elapsedMs} oder {ok:False, error}."""
if self.ws_rvs is None: if self.ws_rvs is None:
return {"ok": False, "error": "RVS-Verbindung nicht aktiv"} return {"ok": False, "error": "RVS-Verbindung nicht aktiv"}
if not isinstance(messages, list) or not messages: if not isinstance(messages, list) or not messages:
@@ -3388,8 +3389,10 @@ class ARIABridge:
} }
if stop: if stop:
req_payload["stop"] = stop req_payload["stop"] = stop
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d)", if tools:
request_id[:8], len(messages), max_tokens) req_payload["tools"] = tools
logger.info("[rvs] llm_request → llm-adapter (id=%s, msgs=%d, max_tokens=%d, tools=%d)",
request_id[:8], len(messages), max_tokens, len(tools) if tools else 0)
ok = await self._send_to_rvs({ ok = await self._send_to_rvs({
"type": "llm_request", "type": "llm_request",
"payload": req_payload, "payload": req_payload,
@@ -3407,6 +3410,7 @@ class ARIABridge:
return { return {
"ok": True, "ok": True,
"content": result.get("content", ""), "content": result.get("content", ""),
"tool_calls": result.get("tool_calls"),
"model": result.get("model"), "model": result.get("model"),
"elapsedMs": result.get("elapsedMs"), "elapsedMs": result.get("elapsedMs"),
} }
@@ -3885,9 +3889,11 @@ class ARIABridge:
temperature = float(data.get("temperature")) temperature = float(data.get("temperature"))
except (TypeError, ValueError): except (TypeError, ValueError):
temperature = 0.7 temperature = 0.7
_tools = data.get("tools") if isinstance(data.get("tools"), list) else None
result = await self._local_llm( result = await self._local_llm(
messages=messages, max_tokens=max_tokens, messages=messages, max_tokens=max_tokens,
temperature=temperature, stop=data.get("stop"), temperature=temperature, stop=data.get("stop"),
tools=_tools,
) )
status = 200 if result.get("ok") else 502 status = 200 if result.get("ok") else 502
await _send_response(writer, status, result) await _send_response(writer, status, result)
+21 -7
View File
@@ -69,9 +69,12 @@ async def _send(ws, mtype: str, payload: dict) -> None:
async def _call_llama(messages: list, *, max_tokens: int, temperature: float, async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
stop) -> dict: stop, tools=None) -> dict:
"""Ruft llama.cpp /v1/chat/completions (OpenAI-Format). Gibt """Ruft llama.cpp /v1/chat/completions (OpenAI-Format). Gibt
{ok, content, error} zurueck — wirft nie.""" {ok, content, tool_calls, error} zurueck — wirft nie.
tools: optionale OpenAI-Tool-Definitionen (B1b). llama.cpp (--jinja) mit
Qwen3 kann natives Tool-Calling und liefert dann message.tool_calls."""
body = { body = {
"model": LLM_MODEL, "model": LLM_MODEL,
"messages": messages, "messages": messages,
@@ -81,6 +84,9 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
} }
if stop: if stop:
body["stop"] = stop body["stop"] = stop
if tools:
body["tools"] = tools
body["tool_choice"] = "auto"
if LLM_DISABLE_THINKING: if LLM_DISABLE_THINKING:
# llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage # llama.cpp (--jinja) reicht chat_template_kwargs an die Chat-Vorlage
# weiter. Qwen3 unterdrueckt damit den <think>-Block. # weiter. Qwen3 unterdrueckt damit den <think>-Block.
@@ -90,8 +96,13 @@ async def _call_llama(messages: list, *, max_tokens: int, temperature: float,
r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body) r = await client.post(f"{LLAMA_URL}/v1/chat/completions", json=body)
r.raise_for_status() r.raise_for_status()
data = r.json() data = r.json()
content = (data.get("choices") or [{}])[0].get("message", {}).get("content", "") msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
return {"ok": True, "content": content or "", "usage": data.get("usage")} return {
"ok": True,
"content": msg.get("content") or "",
"tool_calls": msg.get("tool_calls") or None,
"usage": data.get("usage"),
}
except Exception as e: except Exception as e:
logger.warning("llama.cpp-Call fehlgeschlagen: %s", e) logger.warning("llama.cpp-Call fehlgeschlagen: %s", e)
return {"ok": False, "content": "", "error": str(e)[:300]} return {"ok": False, "content": "", "error": str(e)[:300]}
@@ -108,17 +119,20 @@ async def _handle_llm_request(ws, payload: dict) -> None:
max_tokens = int(payload.get("max_tokens", 512) or 512) max_tokens = int(payload.get("max_tokens", 512) or 512)
temperature = float(payload.get("temperature", 0.7) or 0.7) temperature = float(payload.get("temperature", 0.7) or 0.7)
stop = payload.get("stop") stop = payload.get("stop")
tools = payload.get("tools") or None
t0 = time.time() t0 = time.time()
res = await _call_llama(messages, max_tokens=max_tokens, res = await _call_llama(messages, max_tokens=max_tokens,
temperature=temperature, stop=stop) temperature=temperature, stop=stop, tools=tools)
dt = time.time() - t0 dt = time.time() - t0
logger.info("llm_request id=%s -> ok=%s %.2fs content_len=%d", tc = res.get("tool_calls")
logger.info("llm_request id=%s -> ok=%s %.2fs content_len=%d tool_calls=%d",
(req_id[:8] if req_id else "?"), res.get("ok"), dt, (req_id[:8] if req_id else "?"), res.get("ok"), dt,
len(res.get("content") or "")) len(res.get("content") or ""), len(tc) if tc else 0)
await _send(ws, "llm_response", { await _send(ws, "llm_response", {
"requestId": req_id, "requestId": req_id,
"ok": res.get("ok", False), "ok": res.get("ok", False),
"content": res.get("content", ""), "content": res.get("content", ""),
"tool_calls": tc,
"error": res.get("error"), "error": res.get("error"),
"model": LLM_MODEL, "model": LLM_MODEL,
"elapsedMs": int(dt * 1000), "elapsedMs": int(dt * 1000),