advisor_refine.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383
  1. """The goal-27 review-refinement bundle (task 181): four deliverables
  2. driven end-to-end.
  3. 1. Hidden tool-call bubbles: an assistant row that carries a tool call
  4. and said nothing worth a bubble (empty content or the bare
  5. "@tool call" marker) renders bubble_hidden — the chat page's slot
  6. hides its bubble while the paired tool row keeps its details
  7. expander. A row with real prose alongside the call keeps its bubble.
  8. 2. The chattier persona: the system prompt of every LLM request
  9. carries the conversational contract (address the user after tool
  10. calls, prefer ask_question, never open with a tool call).
  11. 3. Reports in reach: get_goal carries the completion report and
  12. completed_at, get_task the summary and completed_at, the list rows
  13. hint report_chars/summary_chars, and the new get_task_log returns
  14. the task's comments and progress/state-change entries (clamped).
  15. 4. The parking guard: a goal run pushing `suspended` while its goal
  16. owns NO wakeable task is refused — the park mechanics still run,
  17. but the run carries a one-shot pending_resume_notice, is
  18. immediately resume-offerable again, and the daemon's next ask
  19. hands it the refusal text as its resume_notice. With a live task
  20. under the goal the park lands normally (suspended, no notice).
  21. """
  22. from harness import * # noqa: F401,F403 — the shared wire helpers
  23. import harness # the module itself (the re-pointing below reads it)
  24. import bundles.advisor # the imported helpers' module (see run())
  25. from bundles.advisor import (ScriptedLlm, advisor_api, chat_rows,
  26. wait_status)
  27. def restart_with_llm(ctx, llm):
  28. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
  29. os.environ["ADVISOR_LLM_MODEL"] = "scripted"
  30. ctx.app.terminate()
  31. try:
  32. ctx.app.wait(timeout=10)
  33. except subprocess.TimeoutExpired:
  34. ctx.app.kill()
  35. ctx.app = start_app()
  36. ctx.auth = login("admin", "adminpass123")
  37. return ctx.auth
  38. def db_rows(sql, params=()):
  39. conn = db()
  40. try:
  41. return conn.execute(sql, params).fetchall()
  42. finally:
  43. conn.close()
  44. def run(ctx):
  45. # Sequential-suite trap (the shared wire helpers' known shape): the
  46. # port-dependent helpers imported from bundles.advisor read THAT
  47. # module's star-imported globals (WEB &c.), which the harness never
  48. # re-points for it — a solo run passes but the sequential full
  49. # suite dies with ConnectionRefused on the first call. Re-point.
  50. for name in ("WORK", "DB_PATH", "APP_PORT", "DAEMON_PORT", "WEB"):
  51. setattr(bundles.advisor, name, getattr(harness, name))
  52. auth, fake, fake_id, fake_token = ctx.auth, ctx.fake, ctx.fake_id, ctx.fake_token
  53. # ------------------------------------------------------------------
  54. print("== refine 1+2: the prompt contract + the hidden call bubbles ==")
  55. llm = ScriptedLlm([
  56. # A silent tool call (no prose), then one WITH prose, then a
  57. # plain reply — three shapes of assistant row.
  58. {"tools": [{"id": "c1", "name": "list_projects",
  59. "arguments": "{}"}]},
  60. {"content": "Let me look at the goals too.",
  61. "tools": [{"id": "c2", "name": "list_goals",
  62. "arguments": "{}"}]},
  63. {"content": "All read."},
  64. ])
  65. llm.serve()
  66. auth = restart_with_llm(ctx, llm)
  67. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  68. {"title": "Refinements"})
  69. chat_id = int(body["chat_id"])
  70. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  71. {"chat_id": chat_id,
  72. "message": "Take a look around."})
  73. check(status == 200, "the turn is accepted")
  74. check(wait_status(chat_id, "idle"), "the turn completes idle")
  75. check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=20,
  76. what="three LLM calls") is not None,
  77. "the scripted rounds ran")
  78. # (2) the conversational contract rides every request's system prompt
  79. system = llm.requests[0]["messages"][0]
  80. check(system["role"] == "system", "the system prompt leads the wire")
  81. prompt = system["content"]
  82. for needle in ("CONVERSATION",
  83. "ADDRESS THE USER",
  84. "Never open with a",
  85. "2-3 tool calls",
  86. "PREFER ask_question"):
  87. check(needle in prompt,
  88. "the system prompt carries the contract language (%r)" % needle)
  89. for later in llm.requests[1:]:
  90. check(later["messages"][0]["content"] == prompt,
  91. "later rounds keep the same system prompt")
  92. # (1) the bubble gating: the slot's message rows flag the silent
  93. # call row hidden and the prose-alongside-call row visible.
  94. slot = slot_public(auth, "/chats/%d" % chat_id, "chat")
  95. rows = {(int(m["id"])): m for m in slot["messages"]}
  96. call_rows = [m for m in slot["messages"]
  97. if m["kind"] == "assistant" and m.get("tool_name")]
  98. check(len(call_rows) == 2, "two tool-carrying assistant rows exist")
  99. silent = [m for m in call_rows if m.get("bubble_hidden")]
  100. spoken = [m for m in call_rows if not m.get("bubble_hidden")]
  101. check(len(silent) == 1 and len(spoken) == 1,
  102. "the silent call row hides its bubble, the spoken one keeps it")
  103. check(spoken[0]["content"] == "Let me look at the goals too.",
  104. "the kept bubble is the prose row")
  105. tool_details = [m for m in slot["messages"] if m["kind"] == "tool"]
  106. check(len(tool_details) == 2 and all(m.get("tool_name") for m in tool_details),
  107. "the tool rows (the details expanders) still render")
  108. # The page template itself gates on the flag.
  109. page = http_get_page(auth, "/chats/%d" % chat_id)
  110. check("message.bubble_hidden" in page,
  111. "the chat page's assistant bubble is gated on bubble_hidden")
  112. # ------------------------------------------------------------------
  113. print("== refine 3: completion reports within the advisor's reach ==")
  114. # A goal with a completion report + a task with a summary and log
  115. # rows, straight into the DB (the wire's own suites cover the
  116. # transitions; here the TOOLS are under test).
  117. conn = db()
  118. conn.execute(
  119. "insert into goals (project_id, title, brief, branch_name, status,"
  120. " plan, completion_report, completed_at, created_at)"
  121. " values (1, 'Reported goal', 'b', 'goal/reported', 'complete',"
  122. " 'plan text', 'The goal shipped.', strftime('%s','now'),"
  123. " strftime('%s','now'))")
  124. goal_id = conn.execute("select max(id) from goals").fetchone()[0]
  125. conn.execute(
  126. "insert into tasks (project_id, goal_id, kind, title, status,"
  127. " priority, mode, creator, summary, completed_at, created_at)"
  128. " values (1, ?, 'task', 'Reported task', 'done', 5, 'implement',"
  129. " 'human', 'Fixed the flange.', strftime('%s','now'),"
  130. " strftime('%s','now'))", (goal_id,))
  131. task_id = conn.execute("select max(id) from tasks").fetchone()[0]
  132. conn.execute(
  133. "insert into task_comments (task_id, author, body, created_at)"
  134. " values (?, 'agent', 'The flange tests pass now.',"
  135. " strftime('%s','now'))", (task_id,))
  136. conn.execute(
  137. "insert into events (kind, actor, scope_type, scope_id, payload,"
  138. " created_at) values ('progress', 'agent', 'task', ?,"
  139. " '{\"text\": \"wrote the flange tests\"}', strftime('%s','now'))",
  140. (task_id,))
  141. conn.commit()
  142. conn.close()
  143. llm2 = ScriptedLlm([
  144. {"tools": [{"id": "g1", "name": "get_goal",
  145. "arguments": json.dumps({"goal_id": goal_id})}]},
  146. {"tools": [{"id": "g2", "name": "get_task",
  147. "arguments": json.dumps({"task_id": task_id})}]},
  148. {"tools": [{"id": "g3", "name": "get_task_log",
  149. "arguments": json.dumps({"task_id": task_id})}]},
  150. {"content": "Read them all."},
  151. ])
  152. llm2.serve()
  153. auth = restart_with_llm(ctx, llm2)
  154. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  155. {"title": "Reports"})
  156. chat2 = int(body["chat_id"])
  157. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  158. {"chat_id": chat2,
  159. "message": "Pull the completion story."})
  160. check(status == 200, "the report-reading turn is accepted")
  161. # A fresh chat is already 'idle' — wait on the LLM traffic itself.
  162. check(wait_for(lambda: len(llm2.requests) >= 4 or False, timeout=25,
  163. what="the report-reading rounds") is not None,
  164. "the report-reading turn completes")
  165. def tool_result(call_id):
  166. for request in llm2.requests:
  167. for message in request.get("messages") or []:
  168. if message.get("role") == "tool" and \
  169. message.get("tool_call_id") == call_id:
  170. return json.loads(message["content"])
  171. return None
  172. goal_view = tool_result("g1")
  173. check(goal_view is not None and goal_view.get("completion_report")
  174. == "The goal shipped." and goal_view.get("completed_at"),
  175. "get_goal returns the completion report + completed_at")
  176. task_view = tool_result("g2")
  177. check(task_view is not None and task_view.get("summary")
  178. == "Fixed the flange." and task_view.get("completed_at"),
  179. "get_task returns the final summary + completed_at")
  180. log_view = tool_result("g3")
  181. check(log_view is not None, "get_task_log answers")
  182. texts = [e.get("text", "") for e in (log_view.get("log") or [])]
  183. check(any("flange tests pass" in t for t in texts)
  184. and any("wrote the flange tests" in t for t in texts),
  185. "get_task_log carries the comment and the progress entry")
  186. # The list hints: a second turn lists and sees report_chars > 0.
  187. llm3 = ScriptedLlm([
  188. {"tools": [{"id": "l1", "name": "list_goals",
  189. "arguments": "{}"}]},
  190. {"tools": [{"id": "l2", "name": "list_tasks",
  191. "arguments": "{}"}]},
  192. {"content": "Listed."},
  193. ])
  194. llm3.serve()
  195. auth = restart_with_llm(ctx, llm3)
  196. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  197. {"title": "Lists"})
  198. chat3 = int(body["chat_id"])
  199. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  200. {"chat_id": chat3, "message": "List things."})
  201. check(status == 200, "the listing turn is accepted")
  202. check(wait_for(lambda: len(llm3.requests) >= 3 or False, timeout=25,
  203. what="the listing rounds") is not None,
  204. "the listing turn completes")
  205. def tool_result3(call_id):
  206. for request in llm3.requests:
  207. for message in request.get("messages") or []:
  208. if message.get("role") == "tool" and \
  209. message.get("tool_call_id") == call_id:
  210. return json.loads(message["content"])
  211. return None
  212. goals_list = tool_result3("l1") or {}
  213. row = next((g for g in goals_list.get("goals", [])
  214. if g.get("id") == goal_id), None)
  215. check(row is not None and row.get("report_chars") == len("The goal shipped."),
  216. "list_goals hints report_chars (%s)" % row)
  217. tasks_list = tool_result3("l2") or {}
  218. trow = next((t for t in tasks_list.get("tasks", [])
  219. if t.get("id") == task_id), None)
  220. check(trow is not None and trow.get("summary_chars") == len("Fixed the flange."),
  221. "list_tasks hints summary_chars (%s)" % trow)
  222. # The new tool is declared on the wire.
  223. names = [t["function"]["name"] for t in llm.requests[0].get("tools") or []]
  224. check("get_task_log" in names, "get_task_log is declared to the model")
  225. # ------------------------------------------------------------------
  226. print("== refine 4: the parking guard ==")
  227. # Goal A: reaches active with NO owned task, then parks — refused.
  228. # After the refusal resume the script ENDS the run (the notice's
  229. # instruction: finish or cancel) so the churn cannot loop.
  230. fake.goal_script = [
  231. ("plan", "# Plan\n\n1. Park with nothing to wait on"),
  232. ("suspend",),
  233. ("done", "# Completion\n\nNothing to wait on; finished."),
  234. ]
  235. act(auth, slot_public(auth, "/goals", "goals")["create"],
  236. {"project_id": "1", "title": "Guard goal",
  237. "brief": "Parks with zero live tasks.",
  238. "branch_name": "goal/guard"})
  239. guard_goal = int([g for g in slot_public(auth, "/goals", "goals")["goals"]
  240. if g["title"] == "Guard goal"][0]["id"])
  241. def run_task_of(goal_id):
  242. return int(db_rows(
  243. "select id from tasks where goal_id = ? and kind = 'goal_run'",
  244. (goal_id,))[0][0])
  245. def run_status(goal_id):
  246. return task_state(auth, run_task_of(goal_id))["status"]
  247. act(auth, goal_state(guard_goal)["start"], {})
  248. # The app restarts above orphan the fake's scheduled asks — the
  249. # goal run dispatches on an explicit ask (the daemon's own idle
  250. # ask after register).
  251. status, _ = fake.ask_now()
  252. check(wait_for(lambda: goal_state(guard_goal)["status"] == "plan_review"
  253. or False, timeout=90, what="guard goal plans") is not None,
  254. "the guard goal reaches plan review")
  255. fake.goal_script = [("suspend",), ("done",
  256. "# Completion\n\nNothing to wait on; finished.")]
  257. pending = goal_state(guard_goal)["plan_pending_block"]
  258. act(auth, pending["answer_action"], {"verdict": "accept"})
  259. check(wait_for(lambda: goal_state(guard_goal)["status"] == "active"
  260. or False, timeout=60, what="plan approved") is not None,
  261. "approving the plan activates the goal")
  262. check(wait_for(lambda: run_status(guard_goal) == "suspended" or False,
  263. timeout=60, what="the run parks") is not None,
  264. "the run parks (the mechanics still run)")
  265. guard_run = run_task_of(guard_goal)
  266. # The refusal is recorded durably: a system comment on the run.
  267. check(wait_for(lambda: db_rows(
  268. "select count(*) from task_comments where task_id = ?"
  269. " and body like 'Parking refused%'", (guard_run,))[0][0] > 0
  270. or False, timeout=15, what="the refusal comment") is not None,
  271. "the refused park leaves its instruction as a comment")
  272. # The refusal makes the run resume-offerable immediately — the
  273. # dispatcher's wake (or any ask) hands the daemon the notice as
  274. # its resume_notice.
  275. def refusal_resume():
  276. for assignment in fake.goal_assignments:
  277. notice = assignment.get("resume_notice") or ""
  278. if "Parking refused" in notice and "no live tasks" in notice:
  279. return notice
  280. return False
  281. notice = wait_for(refusal_resume, timeout=60, what="the refusal resume")
  282. check(notice is not None,
  283. "the resuming assignment carries the refusal notice")
  284. # One-shot: consumed by the resume that delivered it.
  285. check(wait_for(lambda: not db_rows(
  286. "select pending_resume_notice from tasks where id = ?",
  287. (guard_run,))[0][0] or False, timeout=15,
  288. what="the notice is consumed") is not None,
  289. "the notice is consumed by the resume (one-shot)")
  290. # The instructed ending: the agent finishes its run, the goal
  291. # enters completion review and completes.
  292. check(wait_for(lambda: goal_state(guard_goal)["status"]
  293. == "completion_review" or False, timeout=60,
  294. what="the refused run finishes") is not None,
  295. "the refused run finishes (submit its report)")
  296. pending = goal_state(guard_goal).get("completion_pending_block")
  297. if pending:
  298. act(auth, pending["answer_action"], {"verdict": "accept"})
  299. check(wait_for(lambda: goal_state(guard_goal)["status"] == "complete"
  300. or False, timeout=60, what="the guard goal completes")
  301. is not None,
  302. "accepting the completion completes the guard goal")
  303. # Goal B: the SAME flow but with a live task under the goal — the
  304. # park must land normally (no notice). A BLOCKED live task: still
  305. # wakeable (not terminal), but never dispatched, so nothing fires
  306. # a terminal transition behind the test's back.
  307. fake.goal_script = [("plan", "# Plan\n\n1. Wait on a live task")]
  308. act(auth, slot_public(auth, "/goals", "goals")["create"],
  309. {"project_id": "1", "title": "Unguard goal",
  310. "brief": "Parks with a live task.",
  311. "branch_name": "goal/unguard"})
  312. unguard_goal = int([g for g
  313. in slot_public(auth, "/goals", "goals")["goals"]
  314. if g["title"] == "Unguard goal"][0]["id"])
  315. conn = db()
  316. conn.execute(
  317. "insert into tasks (project_id, goal_id, kind, title, status,"
  318. " priority, mode, creator, created_at)"
  319. " values (1, ?, 'task', 'Live worker', 'blocked', 5, 'implement',"
  320. " 'goal', strftime('%s','now'))", (unguard_goal,))
  321. conn.commit()
  322. conn.close()
  323. act(auth, goal_state(unguard_goal)["start"], {})
  324. status, _ = fake.ask_now()
  325. check(wait_for(lambda: goal_state(unguard_goal)["status"] == "plan_review"
  326. or False, timeout=90, what="unguard goal plans") is not None,
  327. "the unguard goal reaches plan review")
  328. fake.goal_script = [("suspend",)]
  329. pending = goal_state(unguard_goal)["plan_pending_block"]
  330. act(auth, pending["answer_action"], {"verdict": "accept"})
  331. check(wait_for(lambda: goal_state(unguard_goal)["status"] == "active"
  332. or False, timeout=60, what="unguard plan approved")
  333. is not None,
  334. "approving the unguard plan activates the goal")
  335. check(wait_for(lambda: run_status(unguard_goal) == "suspended" or False,
  336. timeout=60, what="unguard run parks") is not None,
  337. "the run with a live task parks normally")
  338. conn = db()
  339. unguard_run = run_task_of(unguard_goal)
  340. plain_row = conn.execute(
  341. "select pending_resume_notice from tasks where id = ?",
  342. (unguard_run,)).fetchone()
  343. conn.close()
  344. check(plain_row is not None and not plain_row[0],
  345. "a park with something to wait on carries no refusal notice")
  346. llm.kill()
  347. llm2.kill()
  348. llm3.kill()