Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion codec_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -660,7 +660,7 @@ def build_session_script(safe_sys, session_id, wake_word_label="CODEC"):
L.append(" c = sqlite3.connect(DB_PATH)")
L.append(" c.execute('CREATE TABLE IF NOT EXISTS conversations (id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT, timestamp TEXT, role TEXT, content TEXT)')")
L.append(" rows = c.execute('SELECT role,content FROM conversations ORDER BY id DESC LIMIT 10').fetchall(); c.close()")
L.append(" if rows: rows.reverse(); prev = [{'role':r,'content':ct} for r,ct in rows]; print('[CODEC] Loaded '+str(len(prev))+' messages from previous sessions.')")
L.append(" if rows: rows.reverse(); prev = [{'role':r,'content':ct} for r,ct in rows if r in ('user','assistant','system')]; print('[CODEC] Loaded '+str(len(prev))+' messages from previous sessions.')")
L.append("except: pass")
L.append("")
L.append("h = [{'role':'system','content':SYS_MSG}] + prev")
Expand Down
25 changes: 24 additions & 1 deletion codec_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,29 @@ def extract_content(response_json: Dict[str, Any]) -> str:
return ""


# The OpenAI-style chat API accepts only these roles. CODEC's memory stores
# other roles internally (notably "fact" from fact_extract / memory_save), and
# more than one caller has replayed those straight into the messages array — a
# hard 422 that surfaces to the user as "Qwen busy". This is the last line of
# defence: coerce anything non-standard to a safe role rather than 422.
_VALID_LLM_ROLES = {"user", "assistant", "system", "developer", "tool"}


def _sanitize_roles(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Return messages with only API-valid roles. A message whose role isn't
recognised (e.g. 'fact') is relabelled 'user' — valid at ANY position, so it
can't trigger a 422 (bad role) or a 500 ('system message must be first' when a
stray fact lands mid-conversation). Its content is preserved so it still
informs the model."""
out: List[Dict[str, Any]] = []
for m in messages:
if m.get("role") in _VALID_LLM_ROLES:
out.append(m)
else:
out.append({**m, "role": "user"})
return out


def _build_request(
messages: List[Dict[str, Any]],
*,
Expand All @@ -101,7 +124,7 @@ def _build_request(
headers["Authorization"] = "Bearer " + api_key
payload: Dict[str, Any] = {
"model": model,
"messages": messages,
"messages": _sanitize_roles(messages),
"max_tokens": max_tokens,
"temperature": temperature,
"chat_template_kwargs": {"enable_thinking": enable_thinking},
Expand Down
8 changes: 7 additions & 1 deletion codec_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -923,7 +923,13 @@ def run(self):
rows = c.execute("SELECT role,content FROM conversations ORDER BY id DESC LIMIT 10").fetchall()
if rows:
rows.reverse()
prev = [{"role": r, "content": ct} for r, ct in rows]
# Only replay real conversation turns. The `conversations` table
# also holds role="fact" rows (from fact_extract / memory_save);
# sending those to the LLM as messages is a hard 422 —
# "role 'fact' invalid" — which then reads as "Qwen busy". Facts
# stay in the DB for the memory system; they just aren't chat turns.
prev = [{"role": r, "content": ct} for r, ct in rows
if r in ("user", "assistant", "system")]
print(f"[C] Loaded {len(prev)} messages from previous sessions.")
else:
prev = []
Expand Down
58 changes: 58 additions & 0 deletions tests/test_llm_role_sanitize.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""codec_llm must never send an API-invalid message role.

2026-07 incident: terminal chat 422'd on every message
("role 'fact' invalid") and surfaced to the user as "Qwen busy". Cause: the
`conversations` table holds role="fact" rows (fact_extract / memory_save) and
the session bootstrap replayed the last N rows verbatim as chat messages.

Two layers now prevent it: the message builders filter facts out, and this —
codec_llm's _build_request — sanitizes any stray non-standard role at the single
chokepoint every call()/stream() goes through.
"""
from __future__ import annotations

import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO))

import codec_llm # noqa: E402


def test_fact_role_is_relabelled_not_dropped():
msgs = [{"role": "fact", "content": "user likes tea"}]
out = codec_llm._sanitize_roles(msgs)
assert out[0]["role"] in codec_llm._VALID_LLM_ROLES
assert out[0]["content"] == "user likes tea", "content must survive"


def test_unknown_role_becomes_user_valid_at_any_position():
"""A stray non-standard role mid-conversation must not become a mid-list
'system' message (some servers 500 on 'system message must be first')."""
msgs = [
{"role": "system", "content": "s"},
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "yo"},
{"role": "fact", "content": "context"},
{"role": "user", "content": "q"},
]
out = codec_llm._sanitize_roles(msgs)
assert out[3]["role"] == "user"
assert all(m["role"] in codec_llm._VALID_LLM_ROLES for m in out)


def test_valid_roles_pass_through_untouched():
for role in ("user", "assistant", "system", "developer", "tool"):
out = codec_llm._sanitize_roles([{"role": role, "content": "x"}])
assert out[0]["role"] == role


def test_build_request_payload_has_only_valid_roles():
msgs = [{"role": "fact", "content": "f"}, {"role": "user", "content": "u"}]
_headers, payload = codec_llm._build_request(
msgs, model="m", api_key="", max_tokens=10, temperature=0.5,
enable_thinking=False, extra_kwargs=None,
)
roles = {m["role"] for m in payload["messages"]}
assert roles <= codec_llm._VALID_LLM_ROLES, f"invalid role reached payload: {roles}"