Skip to content

Commit 3a5125d

Browse files
authored
Merge pull request #49 from cryptopoly/feature/mtp-kvtc-strategy-modernization
Feature/mtp kvtc strategy modernization
2 parents 45d72a6 + 2804d9b commit 3a5125d

47 files changed

Lines changed: 2101 additions & 781 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 43 additions & 10 deletions
Large diffs are not rendered by default.

THIRD_PARTY_NOTICES.md

Lines changed: 3 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@ These may be compiled from source and shipped alongside ChaosEngineAI.
2424
- **Copyright:** Copyright (c) 2023-2026 The ggml authors
2525
- **Binary:** `llama-server-turbo`, `llama-cli-turbo`
2626
- **Usage:** Adds turbo2/3/4 KV cache quantisation types used by the
27-
RotorQuant and TurboQuant cache strategies. Actively maintained fork
28-
with support for recent model architectures (Gemma 4, etc.).
27+
TurboQuant cache strategy. Actively maintained fork with support for
28+
recent model architectures (Gemma 4, etc.).
2929

3030
> **MIT licence notice (applies to both llama.cpp and the TurboQuant fork):**
3131
>
@@ -46,18 +46,6 @@ These may be compiled from source and shipped alongside ChaosEngineAI.
4646
4747
---
4848

49-
## Vendored Packages
50-
51-
### ChaosEngine (PCA-based KV cache compression)
52-
53-
- **Repository:** <https://github.com/cryptopoly/ChaosEngine>
54-
- **Licence:** Apache 2.0
55-
- **Submodule:** `vendor/ChaosEngine`
56-
- **Usage:** Desktop builds may bundle this into the runtime via
57-
`npm run stage:runtime`.
58-
59-
---
60-
6149
## Optional Third-Party Cache Strategies
6250

6351
ChaosEngineAI supports optional cache/compression strategy backends.
@@ -66,8 +54,7 @@ If installed by the user, each is subject to its own licence:
6654
| Strategy | Package | Repository | Licence |
6755
|----------|---------|-----------|---------|
6856
| TriAttention | `triattention` | <https://github.com/WeianMao/triattention> | See upstream |
69-
| RotorQuant (marker) | `turboquant` | <https://github.com/back2matching/turboquant> | Apache 2.0 |
70-
| TurboQuant MLX | `turboquant-mlx` | <https://github.com/sharpner/turboquant-mlx> | MIT |
57+
| TurboQuant MLX | `turboquant-mlx-full` | <https://github.com/arozanov/turboquant-mlx> | MIT |
7158
| MegaKernel || <https://github.com/Luce-Org/luce-megakernel> | See upstream |
7259
| TeaCache (diffusion) | vendored patches | <https://github.com/ali-vilab/TeaCache> | Apache 2.0 |
7360

backend_service/agent.py

Lines changed: 157 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json
1414
import logging
15+
import re
1516
import time
1617
import uuid
1718
from dataclasses import dataclass, field
@@ -51,40 +52,141 @@ class AgentResult:
5152
total_completion_tokens: int = 0
5253

5354

55+
_TOOL_CALL_OPEN = re.compile(r"<tool_call>\s*", re.IGNORECASE)
56+
_TOOL_CALL_CLOSE = re.compile(r"\s*</tool_call>", re.IGNORECASE)
57+
58+
59+
def _strip_tool_call_xml(text: str) -> str:
60+
"""Remove every ``<tool_call>...`` blob from a model response.
61+
62+
FU-040: the chat UI shows ``result.text`` verbatim in the assistant
63+
bubble, so when a model emits a ``<tool_call>`` block AND we
64+
execute the call (either via the engine's structured field or via
65+
``_parse_tool_calls_from_response``), the user sees the same call
66+
twice — once as raw XML noise and once as a ``ToolCallCard``. We
67+
strip the XML from the text we hand back to the streaming layer.
68+
69+
Uses the same ``JSONDecoder.raw_decode`` walk as the parser so we
70+
only remove the well-formed-JSON region the parser actually
71+
consumed; everything around it (the model's natural-language
72+
framing) stays put. A trailing ``</tool_call>`` close tag, when
73+
present, is also swallowed.
74+
"""
75+
if not text or "<tool_call>" not in text.lower():
76+
return text
77+
decoder = json.JSONDecoder()
78+
out: list[str] = []
79+
cursor = 0
80+
while True:
81+
match = _TOOL_CALL_OPEN.search(text, cursor)
82+
if match is None:
83+
out.append(text[cursor:])
84+
break
85+
out.append(text[cursor:match.start()])
86+
start = match.end()
87+
while start < len(text) and text[start].isspace():
88+
start += 1
89+
if start >= len(text):
90+
break
91+
try:
92+
_payload, end = decoder.raw_decode(text, start)
93+
except json.JSONDecodeError:
94+
# Malformed JSON after ``<tool_call>`` — drop the opener
95+
# alone and continue. The garbage payload stays so the
96+
# operator can see what the model emitted.
97+
cursor = match.end()
98+
continue
99+
cursor = end
100+
close = _TOOL_CALL_CLOSE.match(text, cursor)
101+
if close is not None:
102+
cursor = close.end()
103+
cleaned = "".join(out)
104+
# Collapse the double-blank-line that can appear when we strip a
105+
# mid-paragraph tool_call. ``\n\n\n+`` → ``\n\n`` keeps paragraph
106+
# breaks intact while removing the visible gap.
107+
return re.sub(r"\n{3,}", "\n\n", cleaned).strip()
108+
109+
54110
def _parse_tool_calls_from_response(response_text: str) -> list[dict[str, Any]] | None:
55111
"""Attempt to extract tool calls from a text response.
56112
57113
Models using the OpenAI tool-calling protocol return structured
58114
tool_calls in the response object. For models that embed tool calls
59-
in their text output (e.g., Hermes/Functionary format), we try to
60-
parse them from common patterns.
115+
in their text output (e.g. Hermes / NousResearch / Qwen3-Coder-Next),
116+
we parse them from the ``<tool_call>...</tool_call>`` XML-ish
117+
convention.
118+
119+
FU-040 (2026-05-10): widened to handle three real-world shapes
120+
Coder-Next emitted in a single chat session:
121+
122+
1. ``<tool_call>{"name": "x", "arguments": {...}}</tool_call>``
123+
— the canonical Hermes shape. Always worked.
124+
2. ``<tool_call>{"name": "x", "arguments": {...}}`` — no
125+
closing tag. The previous regex required ``</tool_call>``
126+
and silently dropped these, so the model's tool call
127+
rendered as raw XML text in the assistant bubble with no
128+
execution.
129+
3. ``<tool_call> [ {url: ...}, {url: ...} ]`` — model
130+
hallucinated a JSON ARRAY of pseudo-results instead of a
131+
call object. Rejected (the array shape has no ``name`` /
132+
``arguments`` keys to dispatch from), but we keep parsing
133+
so any well-formed call later in the same message still
134+
lands.
135+
136+
The parser walks each ``<tool_call>`` opener and uses the stdlib
137+
``json.JSONDecoder.raw_decode`` to consume exactly the next valid
138+
JSON value (object OR array) — that handles both shapes (1) and
139+
(2) without requiring a closing tag, and shape (3) decodes to a
140+
list which we discard. ``raw_decode`` also correctly skips nested
141+
braces inside argument string values that a naive regex would
142+
choke on.
61143
"""
62-
# Try the <tool_call> XML-ish format (Hermes/NousResearch)
63-
calls: list[dict[str, Any]] = []
64-
import re
144+
if not response_text or "<tool_call>" not in response_text.lower():
145+
return None
65146

66-
for match in re.finditer(
67-
r"<tool_call>\s*(\{.*?\})\s*</tool_call>",
68-
response_text,
69-
re.DOTALL,
70-
):
147+
calls: list[dict[str, Any]] = []
148+
decoder = json.JSONDecoder()
149+
cursor = 0
150+
while True:
151+
match = _TOOL_CALL_OPEN.search(response_text, cursor)
152+
if match is None:
153+
break
154+
start = match.end()
155+
# Find the first non-whitespace character; ``raw_decode`` needs
156+
# to start at the JSON token itself, not at preceding spaces.
157+
while start < len(response_text) and response_text[start].isspace():
158+
start += 1
159+
if start >= len(response_text):
160+
break
71161
try:
72-
payload = json.loads(match.group(1))
73-
name = payload.get("name") or payload.get("function")
74-
arguments = payload.get("arguments") or payload.get("parameters") or {}
75-
if isinstance(arguments, str):
76-
arguments = json.loads(arguments)
77-
if name:
78-
calls.append({
79-
"id": f"call_{uuid.uuid4().hex[:8]}",
80-
"type": "function",
81-
"function": {
82-
"name": name,
83-
"arguments": json.dumps(arguments) if isinstance(arguments, dict) else str(arguments),
84-
},
85-
})
86-
except (json.JSONDecodeError, KeyError):
162+
payload, end = decoder.raw_decode(response_text, start)
163+
except json.JSONDecodeError:
164+
cursor = start + 1
165+
continue
166+
cursor = end
167+
# Shape (3): the model emitted hallucinated results as a list.
168+
# No ``name`` to dispatch from — skip without aborting the
169+
# outer loop so a later well-formed call in the same message
170+
# still gets picked up.
171+
if not isinstance(payload, dict):
87172
continue
173+
name = payload.get("name") or payload.get("function")
174+
if not name:
175+
continue
176+
arguments = payload.get("arguments") or payload.get("parameters") or {}
177+
if isinstance(arguments, str):
178+
try:
179+
arguments = json.loads(arguments)
180+
except json.JSONDecodeError:
181+
arguments = {"raw": arguments}
182+
calls.append({
183+
"id": f"call_{uuid.uuid4().hex[:8]}",
184+
"type": "function",
185+
"function": {
186+
"name": name,
187+
"arguments": json.dumps(arguments) if isinstance(arguments, dict) else str(arguments),
188+
},
189+
})
88190

89191
return calls if calls else None
90192

@@ -99,8 +201,27 @@ def _execute_tool_call(
99201
tool_name = func.get("name", "unknown")
100202
raw_args = func.get("arguments", "{}")
101203

204+
# FU-039 (2026-05-10): coerce ``arguments`` to a dict at the source.
205+
# Models occasionally emit ``{"arguments": null}`` (Coder-Next does
206+
# this when the tool call has no parameters) or send a non-string,
207+
# non-dict shape we don't recognise. Both routes used to set
208+
# ``arguments = None``, which then landed in ``ToolCallResult``,
209+
# serialised into the persisted session, and crashed the frontend's
210+
# ``ToolCallCard`` at ``Object.entries(null)`` on every subsequent
211+
# render. Result: a single bad tool turn permanently bricked the
212+
# Chat tab. Defaulting to ``{}`` keeps the contract consumers
213+
# already assume — and means the frontend boundary (also added in
214+
# FU-039) only fires for genuinely corrupt records, not the common
215+
# "no args" path.
102216
try:
103-
arguments = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
217+
if raw_args is None:
218+
arguments = {}
219+
elif isinstance(raw_args, str):
220+
arguments = json.loads(raw_args) if raw_args.strip() else {}
221+
elif isinstance(raw_args, dict):
222+
arguments = raw_args
223+
else:
224+
arguments = {"raw": raw_args}
104225
except json.JSONDecodeError:
105226
arguments = {"raw": raw_args}
106227

@@ -244,9 +365,12 @@ def run_agent_loop(
244365
tool_calls = _parse_tool_calls_from_response(result.text)
245366

246367
if not tool_calls:
247-
# Model is done — return the final text
368+
# Model is done — return the final text. Strip any
369+
# ``<tool_call>`` XML the parser consumed so the chat
370+
# bubble doesn't show raw call JSON next to a rendered
371+
# ToolCallCard (FU-040).
248372
return AgentResult(
249-
text=result.text,
373+
text=_strip_tool_call_xml(result.text),
250374
tool_calls=all_tool_results,
251375
iterations=iteration + 1,
252376
total_prompt_tokens=total_prompt,
@@ -356,8 +480,11 @@ def run_agent_loop_streaming(
356480

357481
if not tool_calls:
358482
# Final response — stream it token by token for the user
359-
# Since we already have the full text, emit it in chunks
360-
text = result.text
483+
# Since we already have the full text, emit it in chunks.
484+
# Strip any ``<tool_call>`` XML blobs the parser already
485+
# consumed so the assistant bubble doesn't show raw call
486+
# JSON next to the rendered ToolCallCard (FU-040).
487+
text = _strip_tool_call_xml(result.text)
361488
chunk_size = 4
362489
for i in range(0, len(text), chunk_size):
363490
yield {"token": text[i:i + chunk_size]}

backend_service/catalog/text_models.py

Lines changed: 52 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,16 @@
103103
"popularityLabel": "Featured family",
104104
"likesLabel": "Qwen official",
105105
"badges": ["Reasoning", "Coding", "Agents", "Long context"],
106-
"capabilities": ["reasoning", "coding", "tool-use", "vision"],
106+
# FU-040 (2026-05-10): dropped ``vision`` from the family-level
107+
# capabilities. Qwen3.6-27B (dense, Coder-Next branding) and
108+
# Qwen3.6-35B-A3B (MoE) are both text-only — vision lives on a
109+
# separate ``Qwen3.6-27B-VL`` variant we do not yet ship. The
110+
# stale tag was promoting ``supportsVision: true`` for every
111+
# community quant variant, which made ``ChatComposer`` render
112+
# the "Attach image" affordance for a model that has no vision
113+
# encoder. Add it back here only when an actual VL variant
114+
# lands in the catalog.
115+
"capabilities": ["reasoning", "coding", "tool-use"],
107116
"defaultVariantId": "Qwen/Qwen3.6-27B",
108117
"variants": [
109118
{
@@ -115,8 +124,9 @@
115124
"sizeGb": 54.0,
116125
"format": "Transformers",
117126
"quantization": "BF16",
118-
"capabilities": ["reasoning", "coding", "vision", "tool-use"],
119-
"note": "Dense 27B Qwen3.6 release with vision and agentic coding tuning. Apache 2.0.",
127+
# FU-040: text-only dense variant (Coder-Next branding).
128+
"capabilities": ["reasoning", "coding", "tool-use"],
129+
"note": "Dense 27B Qwen3.6 release with agentic coding tuning. Apache 2.0.",
120130
"contextWindow": "262K",
121131
"launchMode": "convert",
122132
"backend": "mlx",
@@ -131,7 +141,8 @@
131141
"sizeGb": 28.0,
132142
"format": "Transformers",
133143
"quantization": "FP8",
134-
"capabilities": ["reasoning", "coding", "vision", "tool-use"],
144+
# FU-040: text-only dense variant.
145+
"capabilities": ["reasoning", "coding", "tool-use"],
135146
"note": "FP8 quantization of the 27B dense release for ~30 GB VRAM systems.",
136147
"contextWindow": "262K",
137148
"launchMode": "convert",
@@ -163,7 +174,8 @@
163174
"sizeGb": 15.5,
164175
"format": "MLX",
165176
"quantization": "4-bit",
166-
"capabilities": ["reasoning", "coding", "vision", "tool-use"],
177+
# FU-040: text-only dense variant.
178+
"capabilities": ["reasoning", "coding", "tool-use"],
167179
"note": "Community MLX 4-bit conversion for Apple Silicon — fastest local launch path.",
168180
"contextWindow": "262K",
169181
"launchMode": "direct",
@@ -239,7 +251,10 @@
239251
"popularityLabel": "Featured family",
240252
"likesLabel": "Qwen official",
241253
"badges": ["Reasoning", "Coding", "Long context"],
242-
"capabilities": ["reasoning", "coding", "tool-use", "vision"],
254+
# FU-040: Qwen3.5 dense + MoE variants are text-only. The
255+
# ``vision`` tag at family-level was promoting false positives
256+
# in ``supportsVision`` for every community quant variant.
257+
"capabilities": ["reasoning", "coding", "tool-use"],
243258
"defaultVariantId": "Qwen/Qwen3.5-9B",
244259
"variants": [
245260
{
@@ -511,6 +526,37 @@
511526
"launchMode": "convert",
512527
"backend": "mlx",
513528
},
529+
# FU-041 (2026-05-10): community MLX 4-bit conversion of the
530+
# Qwen3-Next architecture (qwen3_next, sparse MoE w/ 512
531+
# experts, ~3B active per token, hidden_size=2048). Without
532+
# this variant the library matcher in src/utils/library.ts
533+
# fuzzy-matched a local ``Qwen3-Coder-Next-MLX-4bit`` install
534+
# to the unrelated ``mlx-community/Qwen3.6-27B-4bit`` (dense
535+
# 27B Coder, completely different arch — hidden_size=5120,
536+
# no MoE), which then surfaced the wrong canonicalRepo into
537+
# the runtime snapshot, picked up the wrong capability set,
538+
# and routed DFlash lookups to the wrong drafter. Adding the
539+
# variant explicitly lets the matcher score 80+ on an exact
540+
# repo-path substring hit instead of falling back to the
541+
# closest-quant-and-format match.
542+
{
543+
"id": "lmstudio-community/Qwen3-Coder-Next-MLX-4bit",
544+
"name": "Qwen3 Coder Next MLX 4-bit",
545+
"repo": "lmstudio-community/Qwen3-Coder-Next-MLX-4bit",
546+
"link": "https://huggingface.co/lmstudio-community/Qwen3-Coder-Next-MLX-4bit",
547+
# 80B total params, ~3B active per token; the on-disk
548+
# 4-bit conversion fits ~45 GB.
549+
"paramsB": 80.0,
550+
"sizeGb": 45.0,
551+
"format": "MLX",
552+
"quantization": "4-bit",
553+
"capabilities": ["coding", "agents", "tool-use", "reasoning", "thinking"],
554+
"note": "Community MLX 4-bit conversion of the Qwen3-Next MoE coder for Apple Silicon — fastest local launch path.",
555+
"contextWindow": "262K",
556+
"launchMode": "direct",
557+
"backend": "mlx",
558+
"releaseDate": "2026-04",
559+
},
514560
],
515561
"readme": [
516562
"Qwen3 Coder Next is purpose-built for software engineering with function calling and agentic workflows.",

backend_service/helpers/cache.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ def _estimate_baseline_tok_s(system_stats: dict[str, Any]) -> float:
1717
def _strategy_speed_map(strategy: str) -> dict[int, float]:
1818
"""Speed ratio maps by strategy and bit count (fraction of baseline FP16 speed)."""
1919
maps: dict[str, dict[int, float]] = {
20-
"rotorquant": {1: 0.42, 2: 0.50, 3: 0.57, 4: 0.65},
2120
"triattention": {1: 0.48, 2: 0.56, 3: 0.63, 4: 0.70},
2221
"turboquant": {1: 0.44, 2: 0.52, 3: 0.60, 4: 0.67},
2322
}
@@ -27,7 +26,6 @@ def _strategy_speed_map(strategy: str) -> dict[int, float]:
2726
def _strategy_quality_base(strategy: str) -> dict[int, float]:
2827
"""Base quality percentage by strategy and bit count (before fp16_layers bonus)."""
2928
maps: dict[str, dict[int, float]] = {
30-
"rotorquant": {1: 88.0, 2: 91.0, 3: 93.5, 4: 96.0},
3129
"triattention": {1: 89.5, 2: 92.0, 3: 94.5, 4: 97.0},
3230
"turboquant": {1: 87.5, 2: 90.5, 3: 93.0, 4: 95.5},
3331
}

backend_service/inference/binaries.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,8 +93,8 @@ def _resolve_llama_server() -> str | None:
9393
def _resolve_llama_server_turbo() -> str | None:
9494
"""Resolve the TurboQuant fork of llama-server (``llama-server-turbo``).
9595
96-
This fork supports all standard cache types **plus** iso/planar/turbo
97-
cache types required by RotorQuant and TurboQuant strategies.
96+
This fork supports all standard cache types **plus** turbo2/3/4
97+
cache types required by the TurboQuant strategy.
9898
"""
9999
override = os.getenv("CHAOSENGINE_LLAMA_SERVER_TURBO")
100100
if override:

0 commit comments

Comments
 (0)