fix: bound broad tool workflows

This commit is contained in:
Mikei386
2026-08-24 13:29:41 +02:00
parent 5db73d0a92
commit 308b8a4d1f
11 changed files with 186 additions and 29 deletions
+1 -1
View File
@@ -714,7 +714,7 @@ services:
build:
context: .
dockerfile: platform/openwebui/Dockerfile
image: ${OPENWEBUI_IMAGE:-mike-ai/openwebui:main-01f4282-agent-loop-v6}
image: ${OPENWEBUI_IMAGE:-mike-ai/openwebui:main-01f4282-agent-loop-v7}
container_name: mike-ai-open-webui
restart: unless-stopped
volumes:
+35 -8
View File
@@ -212,6 +212,14 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
result["tool_ids"], ["server:mcp:homeassistant-local"]
)
self.assertIn("Never call ha_list_states merely", result["messages"][0]["content"])
self.assertIn("Never infer an automation entity_id", result["messages"][0]["content"])
async def test_hyphenated_homeassistant_and_tool_name_are_selected(self):
result = await self._select(
"Führe einen Home-Assistant-Test aus und nutze ha_list_states genau einmal."
)
self.assertEqual(result["tool_ids"], ["server:mcp:homeassistant-local"])
async def test_general_web_is_available_without_site_specific_rules(self):
result = await self._select("Erkläre mir kurz, wie ein Fahrrad funktioniert.")
@@ -219,15 +227,15 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
self.assertTrue(result["metadata"]["features"]["web_search"])
self.assertNotIn("tool_ids", result)
async def test_weather_uses_native_web_without_mcp(self):
async def test_weather_gets_native_web_and_compact_fallback(self):
result = await self._select("Soll es heute in Rastatt regnen?")
self.assertNotIn("tool_ids", result)
self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
async def test_youtube_channel_question_uses_native_web(self):
async def test_youtube_channel_question_gets_general_web_fallback(self):
result = await self._select(
"Welches Video steht aktuell oben auf dem YouTube-Kanal The Proper People?"
)
self.assertNotIn("tool_ids", result)
self.assertEqual(result["tool_ids"], ["server:mcp:web-general-local"])
async def test_unraid_uses_readonly_not_mua(self):
result = await self._select(
@@ -272,7 +280,10 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
"aktuelle offizielle Liste online und prüfe jede fehlende Folge bei Deezer. "
"Nur lesen, ohne Downloads oder Änderungen."
)
self.assertEqual(result["tool_ids"], ["server:mcp:mua-readonly-local"])
self.assertEqual(
result["tool_ids"],
["server:mcp:mua-readonly-local", "server:mcp:web-general-local"],
)
self.assertTrue(result["features"]["web_search"])
self.assertTrue(result["metadata"]["features"]["web_search"])
self.assertIn("search_web/fetch_url", result["messages"][0]["content"])
@@ -329,20 +340,36 @@ class AutoToolSelectorTests(unittest.IsolatedAsyncioTestCase):
)
self.assertIn("integrate or relay", result["messages"][0]["content"])
async def test_github_and_explicit_web_only_adds_github_mcp(self):
async def test_readonly_integration_plan_does_not_attach_unraid_admin(self):
result = await self._select(
"Analysiere https://github.com/foo/deemix und prüfe den auf Unraid "
"laufenden Container und entwirf einen Athena-MCP. Erstelle keinen zweiten Container, ändere nichts "
"und lies keine Secrets."
)
self.assertEqual(
result["tool_ids"],
[
"server:mcp:github-local",
"server:mcp:athena-operator-local",
"server:mcp:mua-readonly-local",
],
)
async def test_github_and_explicit_web_adds_compact_general_web(self):
result = await self._select(
"Prüfe dieses GitHub Repository und suche zusätzlich im Netz nach Nutzerstimmen."
)
self.assertEqual(
result["tool_ids"],
["server:mcp:github-local"],
["server:mcp:github-local", "server:mcp:web-general-local"],
)
async def test_plain_public_web_request_does_not_attach_legacy_web_mcp(self):
async def test_plain_public_web_request_attaches_general_not_legacy_web(self):
result = await self._select(
"Suche im Netz auf MakerWorld einen Schlümpfe-Schlüsselanhänger."
)
self.assertNotIn("server:mcp:web-local", result.get("tool_ids", []))
self.assertIn("server:mcp:web-general-local", result.get("tool_ids", []))
async def test_manual_tool_is_preserved(self):
result = await self._select(
+11 -2
View File
@@ -147,7 +147,7 @@ Der isolierte Eignungs- und Ausfalltest ist in
- Docker Compose
- SearXNG, per Digest gepinnt
- TinySearch 0.5.1, per Digest gepinnt
- TinySearch 0.6.1, per Digest gepinnt
- TinySearch ausschließlich im internen Docker-Netz, ohne Host-Port
- lokale ONNX-Embeddings
- OpenWebUI-native allgemeine Suche und Seitenabruf in allen normalen Profilen
@@ -209,7 +209,16 @@ höchstens zwölf Aufrufe desselben Werkzeugnamens und höchstens zweimal exakt
dieselbe Signatur. Nach Ende des Budgets stehen zusätzliche interne Runden ausschließlich
für eine sichtbare werkzeugfreie Schlussantwort bereit. Das produktive
OpenWebUI-Derivat trägt den Tag
`mike-ai/openwebui:main-01f4282-agent-loop-v6`.
`mike-ai/openwebui:main-01f4282-agent-loop-v7`. V7 begrenzt zusätzlich die
Werkzeugantworten in OpenWebUIs internen Fortsetzungsrunden auf 12.000 Zeichen
je Ergebnis und 64.000 Zeichen pro Antwortlauf. Damit greift die Begrenzung
auch bei Ergebnissen, die erst nach dem ersten Request entstehen.
Auto Tool Selector 4.2 hält native Websuche immer verfügbar, ergänzt bei
expliziter Webrecherche den TinySearch-Fallback und erkennt unter anderem
`Home Assistant`, `Home-Assistant` sowie direkte `ha_*`-Werkzeugbezüge. Für
Home Assistant verlangt er gezielte Zustandsabfragen und verbietet die
Ableitung einer `entity_id` aus einer YAML-`id`.
Der Platform Context MCP läuft ohne Docker-Socket, Shell, Egress oder Secrets.
Ein root-eigener Minutentimer erzeugt nur einen begrenzten Laufzeitsnapshot.
+16 -3
View File
@@ -39,8 +39,11 @@ benötigte deshalb kleinere, klarere Werkzeuge und harte Abbruchgrenzen.
erhält der Benutzer deshalb eine sichtbare Antwort aus den vorhandenen
Befunden samt ehrlicher Angabe fehlender Belege. Inlet-Filter allein können
dies nicht erzwingen, weil sie zwischen OpenWebUIs internen Werkzeugrunden
nicht erneut ausgeführt werden. Ein einzelnes Resultat ist auf 12.000, alle
Resultate zusammen auf 64.000 Zeichen begrenzt.
nicht erneut ausgeführt werden. Seit OpenWebUI-Derivat V7 sitzt die
Größenbegrenzung deshalb direkt in der internen Fortsetzungsschleife: ein
einzelnes Resultat ist auf 12.000, alle Resultate zusammen auf 64.000
Zeichen begrenzt. Zitate und sichtbarer Werkzeugstatus werden zuvor
verarbeitet; das Modell erhält eine markierte Kopf-/Ende-Verdichtung.
Die Basis ist unveränderlich auf OpenWebUI-Revision
`01f4282f1ffe0d6212f58d3afbeae21fffd0c4be` beziehungsweise Image-Digest
`sha256:6a773e5c3a246b65cbe74ce942b294292c0e5f81c138f703d111bc162f7d7c3d`
@@ -65,7 +68,7 @@ benötigte deshalb kleinere, klarere Werkzeuge und harte Abbruchgrenzen.
## Abnahme
- OpenWebUI-Filtertests: 34
- OpenWebUI-Filtertests: 36
- Web-MCP-Tests: 9
- Athena-Operator-Tests: 13
- Platform-Context-Test: bestanden
@@ -82,6 +85,16 @@ sudo /opt/mike-ai/stack/dev/verify_mcp_catalogs.sh
Er muss mit `MCP_CATALOG_SUITE_OK` enden.
## Reale Browserabnahme
Die angemeldete OpenWebUI-Sitzung bestand am 24. August 2026 folgende Läufe:
- unbekannte Website MakerWorld über native Suche plus TinySearch 0.6.1
- GitHub-Repository plus vorhandener Unraid-Container plus Athena-Planung
- mehrstufige Home-Assistant-YAML-Analyse ohne Vollinventar
- bewusst angeforderte 223-KB-Klasse einer Home-Assistant-Zustandsliste; V7
lieferte trotz 2.169 Zuständen nach 30,9 Sekunden eine sichtbare Kurzantwort
## Noch manuell zu prüfen
Ein echter Browsertest mit einer bewusst synthetischen CSV benötigt eine
+15 -2
View File
@@ -43,7 +43,9 @@ MCPs sind über feste WireGuard-Ports auch für Hermes und Pi erreichbar.
- bei ausgeschöpftem Budget folgt zwingend eine werkzeugfreie, sichtbare
Schlussantwort aus den bereits erhobenen Befunden
- Werkzeugausgaben bleiben kurz: 12.000 Zeichen je Ergebnis und 64.000 Zeichen
über den Verlauf; ältere Resultate werden zuerst verdichtet
über den Verlauf. Die Begrenzung sitzt in OpenWebUIs interner
Fortsetzungsschleife und greift daher auch auf Resultate, die erst nach dem
ersten Modellschritt entstehen.
Damit stoppt die Plattform bewiesene Schleifen, nicht normale lange Recherche.
Die früheren Grenzen von zwölf Gesamtaufrufen und vier Aufrufen je Werkzeug
@@ -54,7 +56,7 @@ waren für Qwen3.8-Agentenaufgaben zu klein.
| Client | Standardweg |
|---|---|
| OpenWebUI | native `search_web` und `fetch_url`, immer verfügbar |
| Hermes/Pi/andere MCP-Clients | `http://192.168.1.212:8203/mcp` (TinySearch) |
| Hermes/Pi/andere MCP-Clients | `http://192.168.1.212:8203/mcp` (TinySearch; laufender Alt-Gateway zusätzlich 8211 bis zum nächsten geplanten WireGuard-Neustart) |
| Spezial-/Rollbackbedarf | historischer `mcp-web` nur mit Compose-Profil `legacy-web` |
TinySearch stellt die vier Upstream-Werkzeuge `search`, `scrape_urls`,
@@ -81,3 +83,14 @@ Nach Änderungen müssen mindestens folgende Prüfungen erfolgreich sein:
6. Browserlauf mit einer unbekannten öffentlichen Website, GitHub plus
Laufzeitprüfung sowie einer mehrstufigen Home-/Unraid-Aufgabe
Produktive Abnahme am 24. August 2026:
- MakerWorld ohne Site-Adapter: 12 Werkzeugaufrufe, verifizierter Treffer mit
Downloadzahl und Direktlink, sichtbare Antwort nach 102,5 Sekunden
- GitHub + Unraid + Athena: 12 gezielte Repository-Leseaufrufe plus
Laufzeitprüfung; vorhandenen Deemix-Backendcontainer korrekt wiederverwendet
- Home Assistant YAML: drei auskommentierte Automatisierungen gefunden, ohne
vollständige Zustandsliste und mit sichtbarer Abschlussantwort nach 88,0 Sekunden
- absichtlicher Großausgabetest: 2.169 Home-Assistant-Zustände in einem
Werkzeugresultat; durch V7 intern verdichtet und nach 30,9 Sekunden korrekt
mit ausschließlich Anzahl und Testsatz beantwortet
+1 -1
View File
@@ -64,7 +64,7 @@ TinySearch bleibt als Ganzes read-only. Nur das flüchtige tmpfs-Verzeichnis
temporären Browser- und Sitzungszustand erzeugt. Es wird bei jedem
Container-Neustart vollständig verworfen.
TinySearch ist der allgemeine portable Web-MCP. Auf VPN-Port 8203 können Hermes,
TinySearch 0.6.1 ist der allgemeine portable Web-MCP. Auf VPN-Port 8203 können Hermes,
Pi und andere Clients seine vier Upstream-Werkzeuge direkt nutzen. SearXNG ist
der Such-Backenddienst. Die historische eigene Web-Fassade ist nur Rollback.
+3 -1
View File
@@ -56,7 +56,9 @@ services:
tinysearch:
<<: *tool-common
image: marcellm01/tinysearch@sha256:5a03d5a1f1b0fabe48f2a26e05db4a84bcb611106a57ec51e42db4549976aa9c
# TinySearch v0.6.1. This release fixes the crawler behavior observed with
# v0.5.1 and adds the current search -> scrape_urls workflow.
image: marcellm01/tinysearch@sha256:7a7d0585f5000f462e699e42b97409715826a9e2edcd09a166afa93a4b7cba31
container_name: mike-ai-tools-tinysearch
dns: ["${AI_DNS:-1.1.1.1}"]
# Crawl4AI keeps transient browser/session state here. The container stays
+1 -1
View File
@@ -70,7 +70,7 @@ fi
# redundant download can hang indefinitely. Prepare only the persistent ONNX
# bundle that is actually absent on a fresh installation.
docker volume create mike-ai-tools_tinysearch-models >/dev/null
tiny_image="marcellm01/tinysearch@sha256:5a03d5a1f1b0fabe48f2a26e05db4a84bcb611106a57ec51e42db4549976aa9c"
tiny_image="marcellm01/tinysearch@sha256:7a7d0585f5000f462e699e42b97409715826a9e2edcd09a166afa93a4b7cba31"
if ! docker run --rm --entrypoint test \
-v mike-ai-tools_tinysearch-models:/data/models "$tiny_image" \
-f /data/models/all-minilm-l6-v2-onnx/model.onnx; then
@@ -1,7 +1,7 @@
"""
title: MikeAI Auto Tool Selector
author: MikeAI
version: 4.0.0
version: 4.2.0
description: Keeps broad web access available and adds relevant portable MCP domains without acting as a gate.
"""
@@ -31,6 +31,7 @@ class Filter:
"navidrome": "server:mcp:navidrome-local",
"platform": "server:mcp:athena-platform",
"operator": "server:mcp:athena-operator-local",
"web": "server:mcp:web-general-local",
}
LABELS = {
@@ -42,6 +43,7 @@ class Filter:
"navidrome": "Navidrome",
"platform": "Athena-Plattformwissen",
"operator": "Athena Operator",
"web": "allgemeine Websuche (TinySearch)",
}
def __init__(self):
@@ -83,6 +85,7 @@ class Filter:
r"\b(?:web|internet|online|websuche|webrecherche)\b",
r"\b(?:such|recherchier|pr[uü]f|schau)\w*\b.{0,80}\b(?:netz|web|internet|online)\b",
r"\b(?:deezer|youtube|makerworld|wikipedia|docker\s*hub)\b",
r"\b(?:wetter|regen|regnen|vorhersage)\b",
r"\b(?:aktuell|neueste|heute|bis heute)\w*\b.{0,100}\b(?:quelle|liste|verf[uü]gbar|erschienen|video|folge)\w*\b",
),
)
@@ -123,6 +126,20 @@ class Filter:
"contents. Use the read-only shell only when that purpose-built tool "
"cannot answer."
)
if "homeassistant" in selected:
rule += (
" For Home Assistant, start with ha_search, ha_get_state, or an "
"exact entity/configuration query. Never call ha_list_states merely "
"to verify a few referenced entities or automations; use the full "
"state inventory only when the user explicitly requests a complete "
"inventory. For YAML work, inspect the exact file or section first "
"and keep any proposed edit separate from live state verification. "
"Never infer an automation entity_id from its YAML id or alias. After "
"a reload, resolve the actual entity_id through targeted search or the "
"entity registry before calling an automation service. Avoid repeating "
"the same YAML read, state lookup, or validation unless new evidence "
"or a changed configuration makes the repetition necessary."
)
messages = body.setdefault("messages", [])
for message in messages:
if message.get("role") == "system" and isinstance(message.get("content"), str):
@@ -161,7 +178,8 @@ class Filter:
homeassistant = self._matches(
text,
(
r"\bhome\s*assist(?:ant|ent)s?\b", r"\bhomeassistants?\b", r"\bhass\b",
r"\bhome[-\s]*assist(?:ant|ent)s?\b", r"\bhomeassistants?\b", r"\bhass\b",
r"\bha_[a-z0-9_]+\b",
r"\bautomatisierung(?:en)?\b", r"\bentit[aä]t(?:en)?\b",
r"\bautomations?\.ya?ml\b", r"\bconfiguration\.ya?ml\b",
r"\b(?:sensor|light|switch|climate|automation)\.[\w.-]+",
@@ -219,6 +237,7 @@ class Filter:
r"\b(?:keine|keinerlei|nichts?)\b.{0,40}\b(?:[aä]nder|update|aktualisier|durchf[uü]hr|installier|download|umbenenn)\w*\b",
r"\bohne\b.{0,50}\b(?:[aä]nder|update|aktualisier|durchf[uü]hr|installier|download|umbenenn)\w*\b",
r"\b(?:nicht|nichts?)\b.{0,20}\b(?:durchf[uü]hr|ausf[uü]hr|aktualisier|installier)\w*\b",
r"\b(?:[aä]nder|erstell|installier|l[oö]sch|entfern|download|umbenenn)\w*\b.{0,25}\b(?:nichts?|keine[nrms]?|keinerlei)\b",
),
):
unraid_write = False
@@ -282,8 +301,14 @@ class Filter:
return body
latest_text = self._latest_user_text(body)
selected = self._classify(latest_text)
needs_native_web = self._needs_native_web(latest_text)
selected = self._classify(latest_text)
# Web work gets two independent, general-purpose engines. Native
# OpenWebUI search has good result rendering but can return empty sets
# for an otherwise valid query. TinySearch adds only four compact
# tools and provides a portable fallback without site-specific MCPs.
if needs_native_web and "web" not in selected:
selected.append("web")
# General web search is a basic capability, not a site-specific MCP.
# Keep it available on every normal request so an unfamiliar website
# (MakerWorld, eBay, a vendor page tomorrow) never requires another
@@ -298,8 +323,6 @@ class Filter:
metadata_features["web_search"] = True
if not selected:
if needs_native_web:
await self._notify(__event_emitter__, ["native Websuche"])
return body
existing = body.get("tool_ids")
@@ -335,10 +358,12 @@ class Filter:
for message in body.get("messages", []):
if message.get("role") == "system" and isinstance(message.get("content"), str):
message["content"] += (
" Native Open WebUI web search is also enabled for this request. "
"After collecting local or specialist-tool evidence, continue with "
"search_web/fetch_url for current public evidence; do not stop merely "
"because no separate web MCP appears in the MCP tool list."
" Two broad web engines are available: native Open WebUI "
"search_web/fetch_url and TinySearch search/scrape_urls/research. "
"Start with one focused query. If it returns no results, an access "
"block, or weak evidence, switch once to the other engine with a "
"reformulated query. Do not repeat many near-identical searches and "
"do not request or wait for a site-specific MCP."
)
break
await self._notify(__event_emitter__, labels)
@@ -33,6 +33,12 @@ replacement = """ tool_call_iterations = 0
tool_call_executions = 0
max_tool_call_executions = 40
max_executions_per_tool = 12
# Inlet filters only see the first request, not later internal
# tool continuations. Bound the evidence injected into those
# continuations so one broad inventory cannot consume context.
max_single_tool_result_chars = 12000
max_total_tool_result_chars = 64000
tool_result_chars_used = 0
tool_execution_counts = {}
tool_signature_counts = {}
max_tool_call_iterations = getattr(
@@ -96,6 +102,64 @@ if source.count(needle) != 1:
)
source = source.replace(needle, replacement)
needle = """ results.append(
{
'tool_call_id': tool_call_id,
'content': str(tool_result) if tool_result else '',
**({'files': tool_result_files} if tool_result_files else {}),
**({'embeds': tool_result_embeds} if tool_result_embeds else {}),
}
)
"""
replacement = """ # Citations and the visible terminal event above use the
# complete result. Only the model continuation is compacted.
tool_result_text = str(tool_result) if tool_result else ''
remaining_tool_chars = max(
0, max_total_tool_result_chars - tool_result_chars_used
)
allowed_tool_chars = min(
max_single_tool_result_chars, remaining_tool_chars
)
if len(tool_result_text) > allowed_tool_chars:
original_tool_chars = len(tool_result_text)
if allowed_tool_chars >= 800:
marker = (
'\\n\\n[Tool result compacted by Open WebUI: '
f'{original_tool_chars} characters total; middle omitted. '
'Refine the next tool call instead of requesting the same '
'broad inventory again.]\\n\\n'
)
tail_chars = min(2000, allowed_tool_chars // 4)
head_chars = max(
0, allowed_tool_chars - len(marker) - tail_chars
)
tool_result_text = (
tool_result_text[:head_chars]
+ marker
+ tool_result_text[-tail_chars:]
)
else:
tool_result_text = (
'[Tool-result context budget exhausted. Use a more targeted '
'tool call or answer from existing evidence.]'
)[:allowed_tool_chars]
tool_result_chars_used += len(tool_result_text)
results.append(
{
'tool_call_id': tool_call_id,
'content': tool_result_text,
**({'files': tool_result_files} if tool_result_files else {}),
**({'embeds': tool_result_embeds} if tool_result_embeds else {}),
}
)
"""
if source.count(needle) != 1:
raise SystemExit(
f"expected exactly one Open WebUI tool-result anchor, found {source.count(needle)}"
)
source = source.replace(needle, replacement)
needle = """ res = await generate_chat_completion(
request,
new_form_data,
+5 -1
View File
@@ -19,6 +19,10 @@ install -d -m 0700 /data/mike-ai-operator/state /data/mike-ai-operator/repositor
install -m 0755 "$ROOT/platform/operator/athena_operatord.py" /usr/local/libexec/mike-ai-athena-operatord
install -m 0644 "$ROOT/platform/operator/athena-operator.service" /etc/systemd/system/mike-ai-athena-operator.service
systemctl daemon-reload
systemctl enable --now mike-ai-athena-operator.service
systemctl enable mike-ai-athena-operator.service
# The unit may already be active during an in-place upgrade. `enable --now`
# does not reload a running process after its executable was replaced, which
# would leave the MCP facade and executor on different protocol versions.
systemctl restart mike-ai-athena-operator.service
systemctl is-active --quiet mike-ai-athena-operator.service
echo ATHENA_OPERATOR_EXECUTOR_OK