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. # model configuration lives in the model_config row now, so the
  171. # restart below re-reads the DATABASE (set_model_config writes it).
  172. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm.port,
  173. advisor_model="scripted")
  174. ctx.app.terminate()
  175. try:
  176. ctx.app.wait(timeout=10)
  177. except subprocess.TimeoutExpired:
  178. ctx.app.kill()
  179. ctx.app = start_app()
  180. ctx.auth = auth = login("admin", "adminpass123")
  181. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  182. {"title": "Fleet review"})
  183. check(status == 200 and body.get("ok") and int(body.get("chat_id", 0)) > 0,
  184. "POST /api/advisor/chat creates the chat")
  185. chat_id = int(body["chat_id"])
  186. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  187. {"chat_id": chat_id,
  188. "message": "Which project should I target?"})
  189. check(status == 200, "POST /api/advisor/turn accepts the message")
  190. # The tool call round: the request the LLM saw carries the tools
  191. # array and, on the SECOND call, the tool result as a `tool` role
  192. # message (the wire format rebuilt from rows).
  193. check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=15,
  194. what="first LLM call") is not None,
  195. "the turn calls the LLM")
  196. first_request = llm.requests[0]
  197. tool_names = [t["function"]["name"] for t in first_request.get("tools") or []]
  198. check("list_projects" in tool_names and "ask_question" in tool_names
  199. and "suggest_task" in tool_names and "suggest_goal" in tool_names,
  200. "the request declares the advisor tool set")
  201. roles = [m["role"] for m in first_request.get("messages") or []]
  202. check(roles[0] == "system" and "read-only" in
  203. (first_request["messages"][0]["content"]),
  204. "the system prompt leads the messages")
  205. check(any(m.get("role") == "user"
  206. and m.get("content") == "Which project should I target?"
  207. for m in first_request["messages"]),
  208. "the user message rides the request")
  209. check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=15,
  210. what="second LLM call") is not None,
  211. "the tool result triggers the next LLM call")
  212. second_request = llm.requests[1]
  213. tool_rows = [m for m in second_request["messages"] if m.get("role") == "tool"]
  214. check(len(tool_rows) == 1 and tool_rows[0]["tool_call_id"] == "call-1"
  215. and json.loads(tool_rows[0]["content"]).get("projects"),
  216. "the tool result rides back as a `tool` role message with the row data")
  217. assistant_call = [m for m in second_request["messages"]
  218. if m.get("role") == "assistant" and m.get("tool_calls")]
  219. check(len(assistant_call) == 1
  220. and assistant_call[0]["tool_calls"][0]["id"] == "call-1",
  221. "the assistant tool_calls row is reconstructed on the wire")
  222. # ask_question parks: status awaiting_answer, one assistant row
  223. # carrying the question + options payload, and NO further LLM call.
  224. check(wait_status(chat_id, "awaiting_answer"),
  225. "ask_question parks the chat in awaiting_answer")
  226. calls_after_park = len(llm.requests)
  227. questions = [r for r in chat_rows(chat_id, "kind, content, payload")
  228. if r[0] == "assistant" and r[2]
  229. and json.loads(r[2]).get("question")]
  230. check(len(questions) == 1 and questions[0][1]
  231. == "Which project should this target?",
  232. "the question is stored as an assistant row with its payload")
  233. options = json.loads(questions[0][2]).get("options")
  234. check(options == ["demo", "other-project"],
  235. "the question payload carries the options (%s)" % options)
  236. time.sleep(0.5)
  237. check(len(llm.requests) == calls_after_park,
  238. "the parked turn makes no further LLM calls")
  239. print("== advisor: answering resumes the turn ==")
  240. status, body = advisor_api(auth, "POST", "/api/advisor/answer",
  241. {"chat_id": chat_id, "answer": "demo"})
  242. check(status == 200 and body.get("ok"), "the answer is accepted")
  243. check(wait_status(chat_id, "idle"), "the resumed turn ends idle")
  244. check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=15,
  245. what="third LLM call") is not None,
  246. "the answer resumes the LLM loop")
  247. answer_rows = [r for r in chat_rows(chat_id, "kind, content")
  248. if r[1] == "Human selected: demo"]
  249. check(len(answer_rows) == 1 and answer_rows[0][0] == "user",
  250. 'the option answer lands as "Human selected: demo" (user row)')
  251. check(any(r[0] == "assistant" and r[1] == "Understood — targeting demo."
  252. for r in chat_rows(chat_id, "kind, content")),
  253. "the final assistant reply is stored")
  254. third_request = llm.requests[2]
  255. check(any(m.get("role") == "user"
  256. and m.get("content") == "Human selected: demo"
  257. for m in third_request["messages"]),
  258. "the recorded answer rides the next request verbatim")
  259. answered = db_rows("select answer, answered_at from advisor_messages"
  260. " where chat_id = ? and answer is not null", (chat_id,))
  261. check(len(answered) == 1 and answered[0][0] == "demo"
  262. and answered[0][1] is not None,
  263. "the question row keeps the raw answer and its timestamp")
  264. print("== advisor: free-text answer wording ==")
  265. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  266. {"title": "free text"})
  267. free_chat = int(body["chat_id"])
  268. llm2 = ScriptedLlm([
  269. {"tools": [{"id": "f1", "name": "ask_question",
  270. "arguments": json.dumps({"question": "Name it?",
  271. "options": []})}]},
  272. {"content": "Noted."},
  273. ])
  274. llm2.serve()
  275. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm2.port)
  276. ctx.app.terminate()
  277. try:
  278. ctx.app.wait(timeout=10)
  279. except subprocess.TimeoutExpired:
  280. ctx.app.kill()
  281. ctx.app = start_app()
  282. ctx.auth = auth = login("admin", "adminpass123")
  283. advisor_api(auth, "POST", "/api/advisor/turn",
  284. {"chat_id": free_chat, "message": "suggest a name"})
  285. check(wait_status(free_chat, "awaiting_answer"),
  286. "an empty-options question parks too (free-text question)")
  287. status, _ = advisor_api(auth, "POST", "/api/advisor/answer",
  288. {"chat_id": free_chat, "answer": "Zephyr"})
  289. check(wait_status(free_chat, "idle"), "the free-text answer resumes")
  290. check(any(r[1] == "Human answered: Zephyr"
  291. for r in chat_rows(free_chat, "kind, content")),
  292. 'a non-option answer lands as "Human answered: ..."')
  293. llm2.kill()
  294. print("== advisor: suggestions insert pending rows ==")
  295. llm3 = ScriptedLlm([
  296. {"tools": [{"id": "s1", "name": "list_projects",
  297. "arguments": "{}"}]},
  298. {"tools": [{"id": "s2", "name": "suggest_task",
  299. "arguments": json.dumps({
  300. "title": "Advisor smoke test",
  301. "mandate": "Verify the advisor suggestion arc.",
  302. "mode": "test",
  303. "project_id": 1,
  304. "priority": 4})}]},
  305. {"tools": [{"id": "s3", "name": "suggest_goal",
  306. "arguments": json.dumps({
  307. "title": "Advisor goal",
  308. "brief": "Prove goal suggestions work.",
  309. "project_id": 1,
  310. "branch_name": "goal/advisor-smoke"})}]},
  311. {"content": "Two suggestions staged for your approval."},
  312. ])
  313. llm3.serve()
  314. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm3.port)
  315. ctx.app.terminate()
  316. try:
  317. ctx.app.wait(timeout=10)
  318. except subprocess.TimeoutExpired:
  319. ctx.app.kill()
  320. ctx.app = start_app()
  321. ctx.auth = auth = login("admin", "adminpass123")
  322. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  323. {"title": "suggestions"})
  324. sug_chat = int(body["chat_id"])
  325. advisor_api(auth, "POST", "/api/advisor/turn",
  326. {"chat_id": sug_chat, "message": "stage two suggestions"})
  327. check(wait_status(sug_chat, "idle"), "the suggestion turn completes")
  328. # the final assistant reply lands AFTER the status flip in some
  329. # interleavings — wait for the transcript itself to be complete.
  330. check(wait_for(lambda: [r for r in chat_rows(sug_chat, "kind, content")
  331. if r[0] == "assistant"
  332. and r[1] == "Two suggestions staged for your approval."]
  333. or False, timeout=15, what="final reply") is not None,
  334. "the suggestion turn's final reply is stored")
  335. suggestions = db_rows(
  336. "select kind, payload, status, task_id, goal_id, user_id"
  337. " from advisor_suggestions where chat_id = ? order by id", (sug_chat,))
  338. check(len(suggestions) == 2, "two suggestion rows landed")
  339. check(suggestions[0][0] == "task" and suggestions[0][2] == "pending"
  340. and suggestions[0][3] == 0,
  341. "the task suggestion is PENDING with no task created yet")
  342. task_payload = json.loads(suggestions[0][1])
  343. check(task_payload.get("title") == "Advisor smoke test"
  344. and task_payload.get("mandate") == "Verify the advisor suggestion arc."
  345. and task_payload.get("mode") == "test"
  346. and task_payload.get("priority") == 4,
  347. "the task payload keeps every field verbatim")
  348. check(suggestions[1][0] == "goal" and suggestions[1][2] == "pending"
  349. and suggestions[1][4] == 0,
  350. "the goal suggestion is PENDING with no goal created yet")
  351. user_id = db_rows("select user_id from advisor_chats where id = ?",
  352. (sug_chat,))[0][0]
  353. check(suggestions[0][5] == user_id and suggestions[1][5] == user_id,
  354. "suggestions are owned by the chat's user")
  355. check(db_rows("select count(*) from tasks"
  356. " where title = 'Advisor smoke test'")[0][0] == 0,
  357. "NO task row exists before approval")
  358. # The approve flow itself is task 3 of the goal — this bundle only
  359. # proves the rows are inert and correctly owned.
  360. llm3.kill()
  361. print("== advisor: the error path ==")
  362. llm4 = ScriptedLlm([{"content": "never"}])
  363. llm4.serve()
  364. llm4.fail_next = 1
  365. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm4.port)
  366. ctx.app.terminate()
  367. try:
  368. ctx.app.wait(timeout=10)
  369. except subprocess.TimeoutExpired:
  370. ctx.app.kill()
  371. ctx.app = start_app()
  372. ctx.auth = auth = login("admin", "adminpass123")
  373. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  374. {"title": "errors"})
  375. err_chat = int(body["chat_id"])
  376. advisor_api(auth, "POST", "/api/advisor/turn",
  377. {"chat_id": err_chat, "message": "break once"})
  378. check(wait_status(err_chat, "idle"), "the errored turn returns the chat to idle")
  379. check(wait_for(lambda: [r for r in chat_rows(err_chat, "kind, content")
  380. if r[0] == "error"] or False,
  381. timeout=15, what="error row") is not None,
  382. "the failure appends its error message")
  383. error_rows = [r for r in chat_rows(err_chat, "kind, content")
  384. if r[0] == "error"]
  385. check(len(error_rows) == 1 and "failed" in error_rows[0][1].lower(),
  386. "the failure appends an error message row (%s)"
  387. % [r[1] for r in error_rows])
  388. llm4.kill()
  389. print("== advisor: the iteration cap ==")
  390. llm5 = ScriptedLlm([
  391. {"tools": [{"id": "loop-%d" % index, "name": "list_projects",
  392. "arguments": "{}"}]} for index in range(50)
  393. ])
  394. llm5.serve()
  395. set_model_config(advisor_base_url="http://127.0.0.1:%d" % llm5.port)
  396. ctx.app.terminate()
  397. try:
  398. ctx.app.wait(timeout=10)
  399. except subprocess.TimeoutExpired:
  400. ctx.app.kill()
  401. ctx.app = start_app()
  402. ctx.auth = auth = login("admin", "adminpass123")
  403. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  404. {"title": "loop"})
  405. loop_chat = int(body["chat_id"])
  406. advisor_api(auth, "POST", "/api/advisor/turn",
  407. {"chat_id": loop_chat, "message": "loop forever"})
  408. # The terminal state is the ERROR ROW (the status flips to idle
  409. # just before it lands) — waiting for that makes both the round
  410. # count and the row deterministic without sleeps.
  411. check(wait_for(lambda: [r for r in chat_rows(loop_chat, "kind")
  412. if r[0] == "error"] or False,
  413. timeout=30, what="capped turn error row") is not None,
  414. "the capped turn ends with an error row")
  415. check(wait_status(loop_chat, "idle"),
  416. "the capped turn terminates (chat back to idle)")
  417. check(len(llm5.requests) == 12,
  418. "the cap stopped further calls (exactly 12 rounds, %d seen)"
  419. % len(llm5.requests))
  420. llm5.kill()
  421. print("== advisor: per-user privacy ==")
  422. # A second user sees nothing of the first user's chats.
  423. admin_id = db_rows("select user_id from advisor_chats where id = ?",
  424. (chat_id,))[0][0]
  425. second = "bob-" + str(int(time.time()))
  426. users_state = slot_public(auth, "/users", "users")
  427. act(auth, users_state["create"], {"username": second,
  428. "email": second + "@example.com",
  429. "forename": "Bob", "surname": "Advisor",
  430. "date_of_birth": "1990-05-05",
  431. "password": "bobpass123",
  432. "confirm_password": "bobpass123"})
  433. bob = login(second, "bobpass123")
  434. bob_id = db_rows("select id from spry_users where username = ?", (second,))[0][0]
  435. check(bob_id != admin_id, "the second user is distinct")
  436. status, body = advisor_api(bob, "GET", "/api/advisor/chat/%d" % chat_id)
  437. check(status == 404, "another user cannot read the chat (404)")
  438. status, body = advisor_api(bob, "POST", "/api/advisor/turn",
  439. {"chat_id": chat_id, "message": "hi"})
  440. check(status == 404, "another user cannot turn the chat")
  441. status, body = advisor_api(bob, "POST", "/api/advisor/chat/delete",
  442. {"chat_id": chat_id})
  443. check(status == 200 and db_rows(
  444. "select count(*) from advisor_chats where id = ?", (chat_id,))[0][0] == 1,
  445. "another user's delete is a no-op for the row")
  446. print("== advisor: chat list + delete ==")
  447. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  448. {"title": "to delete"})
  449. doomed = int(body["chat_id"])
  450. advisor_api(auth, "POST", "/api/advisor/turn",
  451. {"chat_id": doomed, "message": "populate me"})
  452. # the turn still runs against llm5 (the loop server): wait for the
  453. # terminal error row, not merely the status flip, so delete cannot
  454. # race the final append.
  455. check(wait_for(lambda: [r for r in chat_rows(doomed, "kind")
  456. if r[0] == "error"] or False,
  457. timeout=30, what="populate turn to finish") is not None,
  458. "the populate turn finishes before the delete")
  459. status, _ = advisor_api(auth, "POST", "/api/advisor/chat/delete",
  460. {"chat_id": doomed})
  461. check(status == 200 and db_rows(
  462. "select count(*) from advisor_chats where id = ?", (doomed,))[0][0] == 0,
  463. "delete removes the chat")
  464. check(db_rows("select count(*) from advisor_messages where chat_id = ?",
  465. (doomed,))[0][0] == 0,
  466. "delete cascades the messages")
  467. # Restore the daemon-facing model configuration the other checks
  468. # may rely on (the advisor override is test-local).
  469. set_model_config(advisor_base_url="", advisor_model="")