advisor_utf8.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377
  1. """The advisor UTF-8 wire bundle (invalid-UTF-8 payload fix): a
  2. regression guard per entry point the defect travelled, each driven
  3. end-to-end through the app and the scripted LLM endpoint.
  4. The defect: the advisor's request body (and the stored advisor_messages
  5. payload, re-read on the next turn) carried structurally invalid UTF-8
  6. whenever a multi-byte character straddled a byte-count clamp — every
  7. clamp in AdvisorService was a raw byte cut:
  8. - get_task/get_goal/get_task_log clamp long text to 2000 BYTES; a
  9. completion report whose 2000th byte is mid-character (an em dash —
  10. routine in these reports) emitted an orphaned lead byte. json-glib
  11. embeds it verbatim, the provider 400s the body, and our own parser
  12. rejects the stored row on read-back ("JSON data must be UTF-8
  13. encoded"), silently dropping the turn's content.
  14. - query_canonical_text's 90000-byte head/tail cut had the same shape.
  15. - a provider error body (latin-1 HTML from proxies) rode the chat's
  16. error row unsanitised, with a raw 500-byte cut.
  17. The fix mirrors the daemon's Wire/Canon idiom: cuts land on UTF-8
  18. character boundaries, and every string that reaches a serialisation
  19. boundary is sanitised (invalid bytes -> U+FFFD).
  20. - the completion-report cut: a goal whose completion_report places a
  21. 3-byte em dash at bytes 1999..2001 reaches the LLM as a VALID UTF-8
  22. JSON string, ends on a whole character, and the stored advisor
  23. message payload re-parses
  24. - the task summary cut: the same straddle through get_task
  25. - the canon query middle cut: a canon text over the 90000-byte budget
  26. cut on boundaries on BOTH sides (checked through a fresh canon dir)
  27. - legacy rescue: a stored advisor payload that already holds invalid
  28. bytes (written before the fix) is recovered, not silently dropped
  29. """
  30. from harness import * # noqa: F401,F403 — the shared wire helpers
  31. import harness # the module itself (global re-pointing on restarts)
  32. from bundles import advisor as advisor_module
  33. from bundles.advisor import ScriptedLlm, advisor_api, chat_rows
  34. from bundles.advisor_fix import restart_with_llm, wait_status
  35. def db_rows(sql, params=()):
  36. conn = db()
  37. try:
  38. return conn.execute(sql, params).fetchall()
  39. finally:
  40. conn.close()
  41. def run(ctx):
  42. auth = ctx.auth
  43. # The shared helpers imported from bundles.advisor (advisor_api
  44. # reads WEB) captured THIS module's star-import at load; in the
  45. # sequential full run those globals are the harness's re-pointed
  46. # ones while bundles.advisor's own copy stayed at ITS shard's
  47. # values — re-point them exactly as run_bundle does for us.
  48. for runtime_name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT",
  49. "WEB"):
  50. setattr(advisor_module, runtime_name,
  51. getattr(harness, runtime_name))
  52. # The clamp constant under test (TEXT_CLAMP_CHARS in
  53. # AdvisorService): the straddle is arranged so the em dash's lead
  54. # byte sits exactly AT byte 1999, i.e. byte 2000 lands mid-sequence.
  55. CLAMP = 2000
  56. EM = "\u2014" # — (3 bytes: e2 80 94)
  57. DOT = "\u2026" # … (3 bytes: e2 80 a6)
  58. def straddling(pad_to, tail=" and the closing thought of the report"):
  59. """Text whose multi-byte character straddles byte `pad_to`."""
  60. return "a" * (pad_to - 1) + EM + tail
  61. report = straddling(CLAMP) # em dash at 1999..2001
  62. summary = straddling(CLAMP - 400) # well inside the clamp
  63. long_summary = straddling(CLAMP) # the cut case
  64. print("== advisor_utf8: the completion-report cut ==")
  65. conn = db()
  66. conn.execute(
  67. "insert into goals (project_id, title, brief, branch_name, status,"
  68. " plan, completion_report, completed_at, created_at)"
  69. " values (1, 'UTF8 goal', 'b', 'goal/utf8', 'complete',"
  70. " 'plan text', ?, strftime('%s','now'), strftime('%s','now'))",
  71. (report,))
  72. goal_id = conn.execute("select max(id) from goals").fetchone()[0]
  73. conn.execute(
  74. "insert into tasks (project_id, goal_id, kind, title, status,"
  75. " priority, mode, creator, summary, completed_at, created_at)"
  76. " values (1, ?, 'task', 'UTF8 task', 'done', 5, 'implement',"
  77. " 'human', ?, strftime('%s','now'), strftime('%s','now'))",
  78. (goal_id, long_summary))
  79. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  80. conn.execute(
  81. "insert into tasks (project_id, kind, title, status, priority,"
  82. " mode, creator, summary, created_at)"
  83. " values (1, 'task', 'Short task', 'done', 5, 'implement',"
  84. " 'human', ?, strftime('%s','now'))",
  85. (summary,))
  86. short_id = conn.execute("select max(id) from tasks").fetchone()[0]
  87. conn.commit()
  88. conn.close()
  89. def tool_result(call_id, requests):
  90. for request in requests:
  91. for message in request.get("messages") or []:
  92. if message.get("role") == "tool" and \
  93. message.get("tool_call_id") == call_id:
  94. return message.get("content")
  95. return None
  96. llm = ScriptedLlm([
  97. {"tools": [{"id": "u1", "name": "get_goal",
  98. "arguments": json.dumps({"goal_id": goal_id})}]},
  99. {"tools": [{"id": "u2", "name": "get_task",
  100. "arguments": json.dumps({"task_id": task_id})}]},
  101. {"tools": [{"id": "u3", "name": "get_task",
  102. "arguments": json.dumps({"task_id": short_id})}]},
  103. {"content": "Read them all."},
  104. ])
  105. llm.serve()
  106. auth = restart_with_llm(ctx, llm)
  107. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  108. {"title": "utf8"})
  109. chat_id = int(body["chat_id"])
  110. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  111. {"chat_id": chat_id,
  112. "message": "Pull the completion story."})
  113. check(status == 200, "the report-reading turn is accepted")
  114. check(wait_for(lambda: len(llm.requests) >= 4 or False, timeout=25,
  115. what="the report-reading rounds") is not None,
  116. "the report-reading turn completes")
  117. # EVERY request body the LLM received must be valid UTF-8 JSON. The
  118. # ScriptedLlm already json.loads each body (a raise would 500 the
  119. # turn before any of the checks below), so this inspects the wire
  120. # strings themselves.
  121. def wire_ok():
  122. for index, request in enumerate(llm.requests):
  123. for message in request.get("messages") or []:
  124. content = message.get("content")
  125. if not isinstance(content, str):
  126. continue
  127. try:
  128. content.encode("utf-8", "strict")
  129. except UnicodeEncodeError:
  130. return index, message.get("role")
  131. return None
  132. check(wire_ok() is None,
  133. "every message content on the wire is encodable UTF-8")
  134. # The completion report: cut on a character boundary, never halved.
  135. goal_view = tool_result("u1", llm.requests)
  136. check(goal_view is not None, "get_goal answered")
  137. parsed = json.loads(goal_view) if goal_view else {}
  138. seen = parsed.get("completion_report", "")
  139. check(seen.endswith(DOT) and not seen.endswith(EM[0]),
  140. "the cut completion report ends on the ellipsis marker")
  141. check(EM not in seen,
  142. "the halved em dash is gone (the straddling char was moved out)")
  143. check(len(seen.encode("utf-8")) <= CLAMP + len(DOT.encode()),
  144. "the clamped report respects the byte budget (got %d bytes)"
  145. % len(seen.encode("utf-8")))
  146. # The task summary: same shape through get_task.
  147. task_view = tool_result("u2", llm.requests)
  148. parsed = json.loads(task_view) if task_view else {}
  149. seen = parsed.get("summary", "")
  150. check(seen.endswith(DOT) and EM not in seen,
  151. "the cut task summary ends on the ellipsis marker, char whole")
  152. # A short summary passes through untouched (boundary cut is a no-op).
  153. short_view = tool_result("u3", llm.requests)
  154. parsed = json.loads(short_view) if short_view else {}
  155. check(parsed.get("summary") == summary,
  156. "an under-budget summary rides verbatim")
  157. # The stored advisor_messages payload must re-parse — the row the
  158. # next turn's wire rebuild reads (the pre-fix bug poisoned it).
  159. tool_payloads = [r[0] for r in chat_rows(chat_id, "payload")
  160. if r[0]]
  161. reparsed = 0
  162. for payload in tool_payloads:
  163. doc = json.loads(payload) # raises on invalid UTF-8 JSON
  164. reparsed += 1
  165. inner = doc.get("result")
  166. if isinstance(inner, str):
  167. json.loads(inner) # the double-encoded result
  168. check(reparsed >= 3,
  169. "every stored tool payload re-parses cleanly (%d rows)" % reparsed)
  170. print("== advisor_utf8: the canon query middle cut ==")
  171. # A canon dir with one text over the 90000-byte budget, its cut
  172. # point arranged mid-character, pointed at the app through
  173. # CLANKER_CANON_DIR... which the app reads at startup: restart.
  174. canon_dir = os.path.join(WORK, "canon-utf8")
  175. os.makedirs(canon_dir, exist_ok=True)
  176. BUDGET = 90000
  177. body_text = ("word — dash … ellipsis ✓ check " * 3000)[:BUDGET + 700]
  178. # place a 4-byte emoji so the head cut at BUDGET/2 lands mid-char
  179. body_bytes = body_text.encode("utf-8")
  180. half = BUDGET // 2
  181. body_bytes = (body_bytes[:half] + "\U0001F600".encode("utf-8")
  182. + body_bytes[half:])
  183. with open(os.path.join(canon_dir, "utf8probe.md"), "wb") as handle:
  184. handle.write(
  185. b"---\nslug: utf8probe\ntitle: UTF8 Probe\nauthor: anon\n"
  186. b"source: test\nlicence: pd\nsummary: probe\n---\n")
  187. handle.write(body_bytes)
  188. llm2 = ScriptedLlm([
  189. {"tools": [{"id": "c1", "name": "query_canonical_text",
  190. "arguments": json.dumps({"slug": "utf8probe",
  191. "question": "anything"})}]},
  192. {"content": "Queried."},
  193. ])
  194. llm2.serve()
  195. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm2.port,
  196. advisor_model="scripted")
  197. ctx.app.terminate()
  198. try:
  199. ctx.app.wait(timeout=10)
  200. except subprocess.TimeoutExpired:
  201. ctx.app.kill()
  202. ctx.app = start_app(extra_env={"CLANKER_CANON_DIR": canon_dir})
  203. ctx.auth = auth = login("admin", "adminpass123")
  204. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  205. {"title": "canon utf8"})
  206. canon_chat = int(body["chat_id"])
  207. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  208. {"chat_id": canon_chat,
  209. "message": "query the canon"})
  210. check(status == 200, "the canon-query turn is accepted")
  211. # TWO requests: the main loop's tool round, then the side call the
  212. # tool itself makes (the text never enters the conversation).
  213. check(wait_for(lambda: len(llm2.requests) >= 2 or False, timeout=25,
  214. what="the canon query side call") is not None,
  215. "the canon query reaches the LLM")
  216. # The side call's user message carries the clamped body: it must be
  217. # encodable UTF-8 (the last ScriptedLlm json.loads proves decodable;
  218. # this pins the encoding side of the contract).
  219. side_bodies = [m.get("content", "") for r in llm2.requests
  220. for m in (r.get("messages") or [])
  221. if m.get("role") == "user" and "Text:" in
  222. (m.get("content") or "")]
  223. check(len(side_bodies) >= 1, "the canon side call carried the text")
  224. for text in side_bodies:
  225. try:
  226. text.encode("utf-8", "strict")
  227. ok = True
  228. except UnicodeEncodeError:
  229. ok = False
  230. check(ok, "the canon side-call text is encodable UTF-8")
  231. check("[output truncated:" in text,
  232. "the over-budget canon body states its truncation")
  233. print("== advisor_utf8: legacy stored payload rescue ==")
  234. # A stored advisor_messages row holding invalid UTF-8 (written
  235. # before the fix): the next turn must recover its members through
  236. # the sanitising loader instead of silently dropping them.
  237. legacy_chat_title = "legacy"
  238. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  239. {"title": legacy_chat_title})
  240. legacy_chat = int(body["chat_id"])
  241. conn = db()
  242. dirty_payload = ('{"call_id": "legacy-1", "name": "get_task",'
  243. ' "result": "{\\"note\\": \\"broken \xff bytes\\"}"}')
  244. dirty = dirty_payload.encode("utf-8", "surrogateescape")
  245. # CAST keeps the row TEXT-typed (the shape the pre-fix Vala writer
  246. # stored: sqlite3_bind_text keeps raw bytes); a bare bytes param
  247. # would type it BLOB and the ORM's string mapping would reject the
  248. # ROW — a different failure this bundle does not claim to guard.
  249. conn.execute(
  250. "insert into advisor_messages (chat_id, kind, content, payload,"
  251. " created_at) values (?, 'tool', '@tool result', CAST(? AS TEXT),"
  252. " strftime('%s','now'))", (legacy_chat, dirty))
  253. conn.commit()
  254. conn.close()
  255. llm3 = ScriptedLlm([{"content": "Recovered."}])
  256. llm3.serve()
  257. auth = restart_with_llm(ctx, llm3)
  258. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  259. {"chat_id": legacy_chat,
  260. "message": "carry on"})
  261. check(status == 200, "the legacy-row turn is accepted")
  262. check(wait_for(lambda: len(llm3.requests) >= 1 or False, timeout=25,
  263. what="the legacy-row LLM call") is not None,
  264. "the legacy-row turn reaches the LLM")
  265. legacy_tool = [m for r in llm3.requests for m in (r.get("messages") or [])
  266. if m.get("role") == "tool"
  267. and m.get("tool_call_id") == "legacy-1"]
  268. check(len(legacy_tool) == 1
  269. and "broken" in legacy_tool[0].get("content", ""),
  270. "the legacy dirty row's result survives to the wire (was silently"
  271. " dropped before the sanitising loader)")
  272. print("== advisor_utf8: a provider 400 body with non-UTF-8 bytes ==")
  273. # The provider's error body rides the chat's error row: raw bytes
  274. # from a latin-1 proxy page must not poison the row or the next
  275. # turn's wire rebuild.
  276. class DirtyErrorLlm(ScriptedLlm):
  277. def serve(self):
  278. class Handler(BaseHTTPRequestHandler):
  279. protocol_version = "HTTP/1.1"
  280. def log_message(self, fmt, *args):
  281. pass
  282. def do_POST(self):
  283. length = int(self.headers.get("Content-Length") or 0)
  284. self.rfile.read(length)
  285. # cp1252: the single-byte codepage a real proxy
  286. # error page carries (latin-1 proper cannot hold
  287. # the em dash — cp1252's 0x97 is exactly the
  288. # byte soup this scenario wants).
  289. body = ("<html>Erreur — café naïve</html>"
  290. ).encode("cp1252")
  291. self.send_response(400)
  292. self.send_header("Content-Type", "text/html")
  293. self.send_header("Content-Length", str(len(body)))
  294. self.end_headers()
  295. self.wfile.write(body)
  296. self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
  297. self.port = self.server.server_address[1]
  298. threading.Thread(target=self.server.serve_forever,
  299. daemon=True).start()
  300. return self.server
  301. llm4 = DirtyErrorLlm([])
  302. llm4.serve()
  303. auth = restart_with_llm(ctx, llm4)
  304. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  305. {"title": "dirty 400"})
  306. dirty_chat = int(body["chat_id"])
  307. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  308. {"chat_id": dirty_chat, "message": "go"})
  309. check(status == 200, "the dirty-400 turn is accepted")
  310. # A fresh chat is already 'idle' — wait on the error row itself.
  311. def error_row():
  312. rows = [r for r in chat_rows(dirty_chat, "kind, content")
  313. if r[0] == "error"]
  314. return rows or False
  315. rows = wait_for(error_row, timeout=25, what="the 400 error row")
  316. check(rows is not None and "HTTP 400" in rows[0][1],
  317. "the provider 400 lands in the chat's error row")
  318. try:
  319. rows[0][1].encode("utf-8", "strict")
  320. ok = True
  321. except UnicodeEncodeError:
  322. ok = False
  323. check(ok, "the error row content is encodable UTF-8")
  324. # And the poisoned-error-row chat must still hold a next turn: its
  325. # wire rebuild sanitises the notice instead of failing the turn.
  326. llm5 = ScriptedLlm([{"content": "Still alive."}])
  327. llm5.serve()
  328. auth = restart_with_llm(ctx, llm5)
  329. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  330. {"chat_id": dirty_chat,
  331. "message": "continue after the error"})
  332. check(status == 200, "the post-error turn is accepted")
  333. check(wait_for(lambda: len(llm5.requests) >= 1 or False, timeout=25,
  334. what="the post-error LLM call") is not None,
  335. "the post-error turn reaches the LLM")
  336. notices = [m.get("content", "") for r in llm5.requests
  337. for m in (r.get("messages") or [])
  338. if m.get("role") == "user" and "notice]" in
  339. (m.get("content") or "")]
  340. check(len(notices) >= 1, "the error notice rides the rebuilt wire")
  341. for notice in notices:
  342. try:
  343. notice.encode("utf-8", "strict")
  344. ok = True
  345. except UnicodeEncodeError:
  346. ok = False
  347. check(ok, "the rebuilt error notice is encodable UTF-8")