advisor.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  1. """The advisor backend bundle (goal 27, task 1): the AI Chat loop's
  2. storage + LLM turn machinery, driven end-to-end against an in-process
  3. scripted OpenAI-compatible chat-completions endpoint (the FakeSaddle
  4. pattern). The scenarios:
  5. - the migration: advisor_chats / advisor_messages / advisor_suggestions
  6. exist with the goal-27 shapes
  7. - a full turn: user message → scripted tool call (list_projects, whose
  8. result rides the NEXT request's messages as a `tool` role row) →
  9. ask_question parks the chat ('awaiting_answer', an assistant row
  10. with the options payload, no further LLM call)
  11. - answering: the option is recorded, "Human selected: X" lands as a
  12. user row, and the turn resumes to a plain assistant reply ('idle')
  13. - suggestions: suggest_task inserts a PENDING row owned by the chat's
  14. user; nothing exists in tasks/goals yet
  15. - the error path: one 500 from the endpoint appends a kind='error'
  16. message and the chat returns to idle
  17. - the iteration cap: an endpoint that always answers with a tool call
  18. stops at 12 rounds with an error row, never looping forever
  19. - privacy: a second user cannot see or turn the first user's chat
  20. """
  21. from harness import * # noqa: F401,F403 — the shared wire helpers
  22. class ScriptedLlm:
  23. """In-process OpenAI-compatible chat-completions endpoint: every
  24. POST /chat/completions is inspected (messages + tools) and the
  25. next scripted response is served. `script` is a list of dicts:
  26. {"tools": [{id, name, arguments}]} or {"content": "..."}; an
  27. exhausted script repeats its last entry. `fail_next` makes the
  28. next N requests answer 500."""
  29. def __init__(self, script):
  30. self.script = list(script)
  31. self.cursor = 0
  32. self.requests = []
  33. self.fail_next = 0
  34. self.lock = threading.Lock()
  35. self.port = None
  36. self.server = None
  37. def serve(self):
  38. service = self
  39. class Handler(BaseHTTPRequestHandler):
  40. protocol_version = "HTTP/1.1"
  41. def log_message(self, fmt, *args):
  42. pass
  43. def do_POST(self):
  44. length = int(self.headers.get("Content-Length") or 0)
  45. raw = self.rfile.read(length)
  46. try:
  47. payload = json.loads(raw or b"{}")
  48. except ValueError:
  49. payload = {}
  50. with service.lock:
  51. service.requests.append(payload)
  52. fail = service.fail_next > 0
  53. if fail:
  54. service.fail_next -= 1
  55. index = min(service.cursor, len(service.script) - 1)
  56. step = service.script[index]
  57. service.cursor += 1
  58. if fail:
  59. body = b'{"error": "scripted failure"}'
  60. self.send_response(500)
  61. else:
  62. message = {"role": "assistant"}
  63. if step.get("content") is not None:
  64. message["content"] = step["content"]
  65. calls = step.get("tools") or []
  66. if calls:
  67. message["content"] = step.get("content") or ""
  68. message["tool_calls"] = [
  69. {"id": call["id"], "type": "function",
  70. "function": {"name": call["name"],
  71. "arguments": call["arguments"]}}
  72. for call in calls]
  73. finish = "tool_calls"
  74. else:
  75. finish = "stop"
  76. envelope = {"choices": [{"index": 0, "finish_reason": finish,
  77. "message": message}],
  78. "model": "scripted"}
  79. body = json.dumps(envelope).encode()
  80. self.send_response(200)
  81. self.send_header("Content-Type", "application/json")
  82. self.send_header("Content-Length", str(len(body)))
  83. self.end_headers()
  84. self.wfile.write(body)
  85. self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
  86. self.port = self.server.server_address[1]
  87. threading.Thread(target=self.server.serve_forever, daemon=True).start()
  88. return self.server
  89. def kill(self):
  90. if self.server is not None:
  91. self.server.shutdown()
  92. self.server.server_close()
  93. self.server = None
  94. def advisor_api(auth, method, path, payload=None):
  95. """A login-gated advisor API call with the statum slot header."""
  96. request = urllib.request.Request(
  97. WEB + path, data=json.dumps(payload or {}).encode(), method=method)
  98. request.add_header("Content-Type", "application/json")
  99. request.add_header("X-Statum-Slot",
  100. "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
  101. try:
  102. with urllib.request.urlopen(request, timeout=15) as response:
  103. return response.status, json.loads(response.read().decode() or "{}")
  104. except urllib.error.HTTPError as error:
  105. body = error.read().decode()
  106. try:
  107. return error.code, json.loads(body)
  108. except ValueError:
  109. return error.code, {"raw": body}
  110. def chat_rows(chat_id, what="*"):
  111. conn = db()
  112. try:
  113. return conn.execute(
  114. "select %s from advisor_messages where chat_id = ? order by id"
  115. % what, (chat_id,)).fetchall()
  116. finally:
  117. conn.close()
  118. def wait_status(chat_id, status, timeout=30):
  119. def read():
  120. conn = db()
  121. try:
  122. row = conn.execute(
  123. "select status from advisor_chats where id = ?",
  124. (chat_id,)).fetchone()
  125. return row and row[0] == status and status or False
  126. finally:
  127. conn.close()
  128. return wait_for(read, timeout=timeout,
  129. what="chat %d -> %s" % (chat_id, status))
  130. def run(ctx):
  131. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  132. def db_rows(sql, params=()):
  133. conn = db()
  134. try:
  135. return conn.execute(sql, params).fetchall()
  136. finally:
  137. conn.close()
  138. print("== advisor: the migration ==")
  139. tables = {r[0] for r in db_rows(
  140. "select name from sqlite_master where type = 'table'")}
  141. check({"advisor_chats", "advisor_messages", "advisor_suggestions"} <= tables,
  142. "M0006 creates the three advisor tables on a fresh DB")
  143. chat_columns = [r[1] for r in db_rows("pragma table_info(advisor_chats)")]
  144. check(chat_columns == ["id", "user_id", "title", "status", "created_at",
  145. "updated_at"],
  146. "advisor_chats carries the goal-27 columns (%s)" % chat_columns)
  147. message_columns = [r[1] for r in db_rows("pragma table_info(advisor_messages)")]
  148. check(message_columns == ["id", "chat_id", "kind", "content", "payload",
  149. "answer", "answered_at", "created_at"],
  150. "advisor_messages carries the goal-27 columns (%s)" % message_columns)
  151. suggestion_columns = [r[1] for r in db_rows(
  152. "pragma table_info(advisor_suggestions)")]
  153. check(suggestion_columns == ["id", "chat_id", "user_id", "kind", "payload",
  154. "status", "task_id", "goal_id", "created_at",
  155. "decided_at", "delivered_at"],
  156. "advisor_suggestions carries the goal-27 columns (%s)"
  157. % suggestion_columns)
  158. print("== advisor: a full turn — tool call, question park ==")
  159. llm = ScriptedLlm([
  160. {"tools": [{"id": "call-1", "name": "list_projects",
  161. "arguments": "{}"}]},
  162. {"tools": [{"id": "call-2", "name": "ask_question",
  163. "arguments": json.dumps({
  164. "question": "Which project should this target?",
  165. "options": ["demo", "other-project"]})}]},
  166. {"content": "Understood — targeting demo."},
  167. ])
  168. llm.serve()
  169. # The advisor endpoint must be pointed at the scripted LLM: the
  170. # app restart below re-reads ADVISOR_LLM_BASE_URL.
  171. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
  172. os.environ["ADVISOR_LLM_MODEL"] = "scripted"
  173. ctx.app.terminate()
  174. try:
  175. ctx.app.wait(timeout=10)
  176. except subprocess.TimeoutExpired:
  177. ctx.app.kill()
  178. ctx.app = start_app()
  179. ctx.auth = auth = login("admin", "adminpass123")
  180. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  181. {"title": "Fleet review"})
  182. check(status == 200 and body.get("ok") and int(body.get("chat_id", 0)) > 0,
  183. "POST /api/advisor/chat creates the chat")
  184. chat_id = int(body["chat_id"])
  185. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  186. {"chat_id": chat_id,
  187. "message": "Which project should I target?"})
  188. check(status == 200, "POST /api/advisor/turn accepts the message")
  189. # The tool call round: the request the LLM saw carries the tools
  190. # array and, on the SECOND call, the tool result as a `tool` role
  191. # message (the wire format rebuilt from rows).
  192. check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=15,
  193. what="first LLM call") is not None,
  194. "the turn calls the LLM")
  195. first_request = llm.requests[0]
  196. tool_names = [t["function"]["name"] for t in first_request.get("tools") or []]
  197. check("list_projects" in tool_names and "ask_question" in tool_names
  198. and "suggest_task" in tool_names and "suggest_goal" in tool_names,
  199. "the request declares the advisor tool set")
  200. roles = [m["role"] for m in first_request.get("messages") or []]
  201. check(roles[0] == "system" and "read-only" in
  202. (first_request["messages"][0]["content"]),
  203. "the system prompt leads the messages")
  204. check(any(m.get("role") == "user"
  205. and m.get("content") == "Which project should I target?"
  206. for m in first_request["messages"]),
  207. "the user message rides the request")
  208. check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=15,
  209. what="second LLM call") is not None,
  210. "the tool result triggers the next LLM call")
  211. second_request = llm.requests[1]
  212. tool_rows = [m for m in second_request["messages"] if m.get("role") == "tool"]
  213. check(len(tool_rows) == 1 and tool_rows[0]["tool_call_id"] == "call-1"
  214. and json.loads(tool_rows[0]["content"]).get("projects"),
  215. "the tool result rides back as a `tool` role message with the row data")
  216. assistant_call = [m for m in second_request["messages"]
  217. if m.get("role") == "assistant" and m.get("tool_calls")]
  218. check(len(assistant_call) == 1
  219. and assistant_call[0]["tool_calls"][0]["id"] == "call-1",
  220. "the assistant tool_calls row is reconstructed on the wire")
  221. # ask_question parks: status awaiting_answer, one assistant row
  222. # carrying the question + options payload, and NO further LLM call.
  223. check(wait_status(chat_id, "awaiting_answer"),
  224. "ask_question parks the chat in awaiting_answer")
  225. calls_after_park = len(llm.requests)
  226. questions = [r for r in chat_rows(chat_id, "kind, content, payload")
  227. if r[0] == "assistant" and r[2]
  228. and json.loads(r[2]).get("question")]
  229. check(len(questions) == 1 and questions[0][1]
  230. == "Which project should this target?",
  231. "the question is stored as an assistant row with its payload")
  232. options = json.loads(questions[0][2]).get("options")
  233. check(options == ["demo", "other-project"],
  234. "the question payload carries the options (%s)" % options)
  235. time.sleep(0.5)
  236. check(len(llm.requests) == calls_after_park,
  237. "the parked turn makes no further LLM calls")
  238. print("== advisor: answering resumes the turn ==")
  239. status, body = advisor_api(auth, "POST", "/api/advisor/answer",
  240. {"chat_id": chat_id, "answer": "demo"})
  241. check(status == 200 and body.get("ok"), "the answer is accepted")
  242. check(wait_status(chat_id, "idle"), "the resumed turn ends idle")
  243. check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=15,
  244. what="third LLM call") is not None,
  245. "the answer resumes the LLM loop")
  246. answer_rows = [r for r in chat_rows(chat_id, "kind, content")
  247. if r[1] == "Human selected: demo"]
  248. check(len(answer_rows) == 1 and answer_rows[0][0] == "user",
  249. 'the option answer lands as "Human selected: demo" (user row)')
  250. check(any(r[0] == "assistant" and r[1] == "Understood — targeting demo."
  251. for r in chat_rows(chat_id, "kind, content")),
  252. "the final assistant reply is stored")
  253. third_request = llm.requests[2]
  254. check(any(m.get("role") == "user"
  255. and m.get("content") == "Human selected: demo"
  256. for m in third_request["messages"]),
  257. "the recorded answer rides the next request verbatim")
  258. answered = db_rows("select answer, answered_at from advisor_messages"
  259. " where chat_id = ? and answer is not null", (chat_id,))
  260. check(len(answered) == 1 and answered[0][0] == "demo"
  261. and answered[0][1] is not None,
  262. "the question row keeps the raw answer and its timestamp")
  263. print("== advisor: free-text answer wording ==")
  264. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  265. {"title": "free text"})
  266. free_chat = int(body["chat_id"])
  267. llm2 = ScriptedLlm([
  268. {"tools": [{"id": "f1", "name": "ask_question",
  269. "arguments": json.dumps({"question": "Name it?",
  270. "options": []})}]},
  271. {"content": "Noted."},
  272. ])
  273. llm2.serve()
  274. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm2.port
  275. ctx.app.terminate()
  276. try:
  277. ctx.app.wait(timeout=10)
  278. except subprocess.TimeoutExpired:
  279. ctx.app.kill()
  280. ctx.app = start_app()
  281. ctx.auth = auth = login("admin", "adminpass123")
  282. advisor_api(auth, "POST", "/api/advisor/turn",
  283. {"chat_id": free_chat, "message": "suggest a name"})
  284. check(wait_status(free_chat, "awaiting_answer"),
  285. "an empty-options question parks too (free-text question)")
  286. status, _ = advisor_api(auth, "POST", "/api/advisor/answer",
  287. {"chat_id": free_chat, "answer": "Zephyr"})
  288. check(wait_status(free_chat, "idle"), "the free-text answer resumes")
  289. check(any(r[1] == "Human answered: Zephyr"
  290. for r in chat_rows(free_chat, "kind, content")),
  291. 'a non-option answer lands as "Human answered: ..."')
  292. llm2.kill()
  293. print("== advisor: suggestions insert pending rows ==")
  294. llm3 = ScriptedLlm([
  295. {"tools": [{"id": "s1", "name": "list_projects",
  296. "arguments": "{}"}]},
  297. {"tools": [{"id": "s2", "name": "suggest_task",
  298. "arguments": json.dumps({
  299. "title": "Advisor smoke test",
  300. "mandate": "Verify the advisor suggestion arc.",
  301. "mode": "test",
  302. "project_id": 1,
  303. "priority": 4})}]},
  304. {"tools": [{"id": "s3", "name": "suggest_goal",
  305. "arguments": json.dumps({
  306. "title": "Advisor goal",
  307. "brief": "Prove goal suggestions work.",
  308. "project_id": 1,
  309. "branch_name": "goal/advisor-smoke"})}]},
  310. {"content": "Two suggestions staged for your approval."},
  311. ])
  312. llm3.serve()
  313. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm3.port
  314. ctx.app.terminate()
  315. try:
  316. ctx.app.wait(timeout=10)
  317. except subprocess.TimeoutExpired:
  318. ctx.app.kill()
  319. ctx.app = start_app()
  320. ctx.auth = auth = login("admin", "adminpass123")
  321. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  322. {"title": "suggestions"})
  323. sug_chat = int(body["chat_id"])
  324. advisor_api(auth, "POST", "/api/advisor/turn",
  325. {"chat_id": sug_chat, "message": "stage two suggestions"})
  326. check(wait_status(sug_chat, "idle"), "the suggestion turn completes")
  327. # the final assistant reply lands AFTER the status flip in some
  328. # interleavings — wait for the transcript itself to be complete.
  329. check(wait_for(lambda: [r for r in chat_rows(sug_chat, "kind, content")
  330. if r[0] == "assistant"
  331. and r[1] == "Two suggestions staged for your approval."]
  332. or False, timeout=15, what="final reply") is not None,
  333. "the suggestion turn's final reply is stored")
  334. suggestions = db_rows(
  335. "select kind, payload, status, task_id, goal_id, user_id"
  336. " from advisor_suggestions where chat_id = ? order by id", (sug_chat,))
  337. check(len(suggestions) == 2, "two suggestion rows landed")
  338. check(suggestions[0][0] == "task" and suggestions[0][2] == "pending"
  339. and suggestions[0][3] == 0,
  340. "the task suggestion is PENDING with no task created yet")
  341. task_payload = json.loads(suggestions[0][1])
  342. check(task_payload.get("title") == "Advisor smoke test"
  343. and task_payload.get("mandate") == "Verify the advisor suggestion arc."
  344. and task_payload.get("mode") == "test"
  345. and task_payload.get("priority") == 4,
  346. "the task payload keeps every field verbatim")
  347. check(suggestions[1][0] == "goal" and suggestions[1][2] == "pending"
  348. and suggestions[1][4] == 0,
  349. "the goal suggestion is PENDING with no goal created yet")
  350. user_id = db_rows("select user_id from advisor_chats where id = ?",
  351. (sug_chat,))[0][0]
  352. check(suggestions[0][5] == user_id and suggestions[1][5] == user_id,
  353. "suggestions are owned by the chat's user")
  354. check(db_rows("select count(*) from tasks"
  355. " where title = 'Advisor smoke test'")[0][0] == 0,
  356. "NO task row exists before approval")
  357. # The approve flow itself is task 3 of the goal — this bundle only
  358. # proves the rows are inert and correctly owned.
  359. llm3.kill()
  360. print("== advisor: the error path ==")
  361. llm4 = ScriptedLlm([{"content": "never"}])
  362. llm4.serve()
  363. llm4.fail_next = 1
  364. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm4.port
  365. ctx.app.terminate()
  366. try:
  367. ctx.app.wait(timeout=10)
  368. except subprocess.TimeoutExpired:
  369. ctx.app.kill()
  370. ctx.app = start_app()
  371. ctx.auth = auth = login("admin", "adminpass123")
  372. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  373. {"title": "errors"})
  374. err_chat = int(body["chat_id"])
  375. advisor_api(auth, "POST", "/api/advisor/turn",
  376. {"chat_id": err_chat, "message": "break once"})
  377. check(wait_status(err_chat, "idle"), "the errored turn returns the chat to idle")
  378. check(wait_for(lambda: [r for r in chat_rows(err_chat, "kind, content")
  379. if r[0] == "error"] or False,
  380. timeout=15, what="error row") is not None,
  381. "the failure appends its error message")
  382. error_rows = [r for r in chat_rows(err_chat, "kind, content")
  383. if r[0] == "error"]
  384. check(len(error_rows) == 1 and "failed" in error_rows[0][1].lower(),
  385. "the failure appends an error message row (%s)"
  386. % [r[1] for r in error_rows])
  387. llm4.kill()
  388. print("== advisor: the iteration cap ==")
  389. llm5 = ScriptedLlm([
  390. {"tools": [{"id": "loop-%d" % index, "name": "list_projects",
  391. "arguments": "{}"}]} for index in range(50)
  392. ])
  393. llm5.serve()
  394. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm5.port
  395. ctx.app.terminate()
  396. try:
  397. ctx.app.wait(timeout=10)
  398. except subprocess.TimeoutExpired:
  399. ctx.app.kill()
  400. ctx.app = start_app()
  401. ctx.auth = auth = login("admin", "adminpass123")
  402. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  403. {"title": "loop"})
  404. loop_chat = int(body["chat_id"])
  405. advisor_api(auth, "POST", "/api/advisor/turn",
  406. {"chat_id": loop_chat, "message": "loop forever"})
  407. # The terminal state is the ERROR ROW (the status flips to idle
  408. # just before it lands) — waiting for that makes both the round
  409. # count and the row deterministic without sleeps.
  410. check(wait_for(lambda: [r for r in chat_rows(loop_chat, "kind")
  411. if r[0] == "error"] or False,
  412. timeout=30, what="capped turn error row") is not None,
  413. "the capped turn ends with an error row")
  414. check(wait_status(loop_chat, "idle"),
  415. "the capped turn terminates (chat back to idle)")
  416. check(len(llm5.requests) == 12,
  417. "the cap stopped further calls (exactly 12 rounds, %d seen)"
  418. % len(llm5.requests))
  419. llm5.kill()
  420. print("== advisor: per-user privacy ==")
  421. # A second user sees nothing of the first user's chats.
  422. admin_id = db_rows("select user_id from advisor_chats where id = ?",
  423. (chat_id,))[0][0]
  424. second = "bob-" + str(int(time.time()))
  425. users_state = slot_public(auth, "/users", "users")
  426. act(auth, users_state["create"], {"username": second,
  427. "email": second + "@example.com",
  428. "forename": "Bob", "surname": "Advisor",
  429. "date_of_birth": "1990-05-05",
  430. "password": "bobpass123",
  431. "confirm_password": "bobpass123"})
  432. bob = login(second, "bobpass123")
  433. bob_id = db_rows("select id from spry_users where username = ?", (second,))[0][0]
  434. check(bob_id != admin_id, "the second user is distinct")
  435. status, body = advisor_api(bob, "GET", "/api/advisor/chat/%d" % chat_id)
  436. check(status == 404, "another user cannot read the chat (404)")
  437. status, body = advisor_api(bob, "POST", "/api/advisor/turn",
  438. {"chat_id": chat_id, "message": "hi"})
  439. check(status == 404, "another user cannot turn the chat")
  440. status, body = advisor_api(bob, "POST", "/api/advisor/chat/delete",
  441. {"chat_id": chat_id})
  442. check(status == 200 and db_rows(
  443. "select count(*) from advisor_chats where id = ?", (chat_id,))[0][0] == 1,
  444. "another user's delete is a no-op for the row")
  445. print("== advisor: chat list + delete ==")
  446. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  447. {"title": "to delete"})
  448. doomed = int(body["chat_id"])
  449. advisor_api(auth, "POST", "/api/advisor/turn",
  450. {"chat_id": doomed, "message": "populate me"})
  451. # the turn still runs against llm5 (the loop server): wait for the
  452. # terminal error row, not merely the status flip, so delete cannot
  453. # race the final append.
  454. check(wait_for(lambda: [r for r in chat_rows(doomed, "kind")
  455. if r[0] == "error"] or False,
  456. timeout=30, what="populate turn to finish") is not None,
  457. "the populate turn finishes before the delete")
  458. status, _ = advisor_api(auth, "POST", "/api/advisor/chat/delete",
  459. {"chat_id": doomed})
  460. check(status == 200 and db_rows(
  461. "select count(*) from advisor_chats where id = ?", (doomed,))[0][0] == 0,
  462. "delete removes the chat")
  463. check(db_rows("select count(*) from advisor_messages where chat_id = ?",
  464. (doomed,))[0][0] == 0,
  465. "delete cascades the messages")
  466. # Restore the daemon-facing LLM env the other checks may rely on
  467. # (the advisor env is test-local).
  468. os.environ.pop("ADVISOR_LLM_BASE_URL", None)
  469. os.environ.pop("ADVISOR_LLM_MODEL", None)