canon.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """The canon/ethos centralisation bundle (goal 27, review refinement):
  2. the WEB becomes the single owner of the canonical texts and the ethos —
  3. served over the per-saddle-token API the daemon already speaks, carried
  4. inline on every assignment, and surfaced to the advisor through the
  5. same three tools the daemon's ToolLoop exposes. The scenarios:
  6. - GET /api/saddle/canon lists the library (15 texts, headers parsed:
  7. slug/title/author/source/licence/summary plus words and bytes)
  8. - GET /api/saddle/canon/{slug} returns one full document; an unknown
  9. slug answers 404
  10. - every canon/ethos route requires the bearer token (401 without)
  11. - GET /api/saddle/ethos serves the one web-side ethos text
  12. - a fresh assignment payload (the fake saddle's next-task ask) carries
  13. the same text as its `ethos` member
  14. - the advisor's tool set declares list/read/query_canonical_text, its
  15. system prompt carries the ethos section verbatim, a scripted turn
  16. lists then reads a canon text, and query_canonical_text's side call
  17. hits the same scripted endpoint
  18. """
  19. from harness import * # noqa: F401,F403 — the shared wire helpers
  20. from bundles.advisor import ScriptedLlm # port-independent helper
  21. def advisor_api(auth, method, path, payload=None):
  22. """A login-gated advisor API call with the statum slot header."""
  23. request = urllib.request.Request(
  24. WEB + path, data=json.dumps(payload or {}).encode(), method=method)
  25. request.add_header("Content-Type", "application/json")
  26. request.add_header("X-Statum-Slot",
  27. "key=%s; as_at=%s" % (auth["key"], auth["as_at"]))
  28. try:
  29. with urllib.request.urlopen(request, timeout=15) as response:
  30. return response.status, json.loads(response.read().decode() or "{}")
  31. except urllib.error.HTTPError as error:
  32. body = error.read().decode()
  33. try:
  34. return error.code, json.loads(body)
  35. except ValueError:
  36. return error.code, {"raw": body}
  37. def chat_rows(chat_id, what="*"):
  38. conn = db()
  39. try:
  40. return conn.execute(
  41. "select %s from advisor_messages where chat_id = ? order by id"
  42. % what, (chat_id,)).fetchall()
  43. finally:
  44. conn.close()
  45. def wait_status(chat_id, status, timeout=30):
  46. def read():
  47. conn = db()
  48. try:
  49. row = conn.execute(
  50. "select status from advisor_chats where id = ?",
  51. (chat_id,)).fetchone()
  52. return row and row[0] == status and status or False
  53. finally:
  54. conn.close()
  55. return wait_for(read, timeout=timeout,
  56. what="chat %d -> %s" % (chat_id, status))
  57. def run(ctx):
  58. auth, fake, fake_token = ctx.auth, ctx.fake, ctx.fake_token
  59. print("== canon: the listing (bearer-gated) ==")
  60. status, body = saddle_api("GET", "/api/saddle/canon", fake_token)
  61. check(status == 200 and body.get("ok") is True,
  62. "GET /api/saddle/canon answers ok with the saddle token")
  63. texts = body.get("texts") or []
  64. check(body.get("count") == 15 and len(texts) == 15,
  65. "the listing carries all 15 canon texts (%s)" % body.get("count"))
  66. slugs = {t.get("slug") for t in texts}
  67. check({"gnu-manifesto", "free-software-definition", "plato-republic-vi",
  68. "magnifica-humanitas-summary"} <= slugs,
  69. "the expected slugs are present")
  70. gnu = [t for t in texts if t.get("slug") == "gnu-manifesto"][0]
  71. check(gnu.get("title") == "The GNU Manifesto"
  72. and gnu.get("author") == "Richard Stallman"
  73. and len(gnu.get("licence") or "") > 5
  74. and len(gnu.get("source") or "") > 5
  75. and int(gnu.get("words", 0)) > 100
  76. and int(gnu.get("bytes", 0)) > 1000
  77. and len(gnu.get("summary") or "") > 10,
  78. "the parsed header of gnu-manifesto is complete (%s)"
  79. % sorted(gnu.keys()))
  80. print("== canon: one document by slug ==")
  81. status, doc = saddle_api("GET", "/api/saddle/canon/free-software-definition",
  82. fake_token)
  83. check(status == 200 and doc.get("ok") is True
  84. and doc.get("slug") == "free-software-definition"
  85. and "Four Essential Freedoms".lower() in (doc.get("body") or "").lower(),
  86. "GET /api/saddle/canon/{slug} returns the full document")
  87. check(doc.get("title") and doc.get("author") and doc.get("summary")
  88. and int(doc.get("bytes", 0)) > 1000,
  89. "the document carries the header fields and size")
  90. status, missing = saddle_api("GET", "/api/saddle/canon/no-such-text",
  91. fake_token)
  92. check(status == 404 and missing.get("ok") is False,
  93. "an unknown slug answers 404")
  94. print("== canon + ethos: the bearer token gates every route ==")
  95. for path in ("/api/saddle/canon", "/api/saddle/canon/plato-apology",
  96. "/api/saddle/ethos"):
  97. status, _ = saddle_api("GET", path, None)
  98. check(status == 401, "%s without a token answers 401" % path)
  99. status, _ = saddle_api("GET", "/api/saddle/canon", "not-a-token")
  100. check(status == 401, "a bogus token answers 401")
  101. print("== ethos: the served text and the assignment member ==")
  102. status, ethos_body = saddle_api("GET", "/api/saddle/ethos", fake_token)
  103. check(status == 200 and ethos_body.get("ok") is True,
  104. "GET /api/saddle/ethos answers ok")
  105. ethos = ethos_body.get("ethos") or ""
  106. check(ethos.startswith("ETHOS") and "cosmic order" in ethos
  107. and "order of goods" in ethos and "servant, not a oracle" in ethos,
  108. "the ethos text carries the daemon's section verbatim")
  109. # The assignment the fake saddle receives must carry the same text
  110. # inline (no second fetch needed): a queued task dispatches to it.
  111. fake.settle_asks(timeout=10)
  112. payload_count = len(fake.start_payloads)
  113. create_queued(auth, {"project_id": "1",
  114. "title": "Canon ethos carrier",
  115. "priority": "2"})
  116. check(wait_for(lambda: len(fake.start_payloads) > payload_count or False,
  117. timeout=30, what="the canon probe assignment") is not None,
  118. "the fake saddle adopted a fresh assignment")
  119. payload = fake.start_payloads[-1]
  120. check(payload.get("ethos") == ethos,
  121. "a fresh assignment payload carries the ethos verbatim")
  122. print("== advisor: the canon tool surface ==")
  123. llm = ScriptedLlm([
  124. {"tools": [{"id": "call-c1", "name": "list_canonical_texts",
  125. "arguments": "{}"}]},
  126. {"tools": [{"id": "call-c2", "name": "read_canonical_text",
  127. "arguments": json.dumps(
  128. {"slug": "gnu-manifesto", "offset": 1, "limit": 20})}]},
  129. {"tools": [{"id": "call-c3", "name": "query_canonical_text",
  130. "arguments": json.dumps(
  131. {"slug": "free-software-definition",
  132. "question": "What are the four freedoms?"})}]},
  133. {"content": "The four freedoms are use, study, share and improve."},
  134. ])
  135. llm.serve()
  136. os.environ["ADVISOR_LLM_BASE_URL"] = "http://127.0.0.1:%d" % llm.port
  137. os.environ["ADVISOR_LLM_MODEL"] = "scripted"
  138. ctx.app.terminate()
  139. try:
  140. ctx.app.wait(timeout=10)
  141. except subprocess.TimeoutExpired:
  142. ctx.app.kill()
  143. ctx.app = start_app()
  144. ctx.auth = auth = login("admin", "adminpass123")
  145. status, body = advisor_api(auth, "POST", "/api/advisor/chat",
  146. {"title": "Canon grounding"})
  147. check(status == 200 and body.get("ok"), "the chat is created")
  148. chat_id = int(body["chat_id"])
  149. status, _ = advisor_api(auth, "POST", "/api/advisor/turn",
  150. {"chat_id": chat_id,
  151. "message": "Ground your advice in the canon."})
  152. check(status == 200, "the turn is accepted")
  153. check(wait_for(lambda: len(llm.requests) >= 1 or False, timeout=15,
  154. what="first advisor LLM call") is not None,
  155. "the advisor calls the LLM")
  156. first_request = llm.requests[0]
  157. tool_names = [t["function"]["name"] for t in first_request.get("tools") or []]
  158. check("list_canonical_texts" in tool_names
  159. and "read_canonical_text" in tool_names
  160. and "query_canonical_text" in tool_names,
  161. "the advisor declares the three canon tools")
  162. system = first_request["messages"][0]
  163. check(system.get("role") == "system"
  164. and "cosmic order" in system.get("content", "")
  165. and "order of goods" in system.get("content", ""),
  166. "the advisor system prompt carries the ethos section")
  167. # The list tool result rides back to the next request.
  168. check(wait_for(lambda: len(llm.requests) >= 2 or False, timeout=15,
  169. what="the list result round") is not None,
  170. "list_canonical_texts triggers the next round")
  171. second_request = llm.requests[1]
  172. tool_rows = [m for m in second_request["messages"] if m.get("role") == "tool"]
  173. listed = json.loads(tool_rows[0]["content"]) if tool_rows else {}
  174. check(tool_rows and tool_rows[0].get("tool_call_id") == "call-c1"
  175. and listed.get("count") == 15
  176. and any(t.get("slug") == "gnu-manifesto"
  177. for t in listed.get("texts") or []),
  178. "list_canonical_texts returns the 15-text library")
  179. # The read tool result: a numbered window with the title riding along.
  180. check(wait_for(lambda: len(llm.requests) >= 3 or False, timeout=15,
  181. what="the read result round") is not None,
  182. "read_canonical_text triggers the next round")
  183. third_request = llm.requests[2]
  184. read_rows = [m for m in third_request["messages"] if m.get("role") == "tool"]
  185. read = None
  186. for row in read_rows:
  187. parsed = json.loads(row["content"])
  188. if parsed.get("slug") == "gnu-manifesto" and "content" in parsed:
  189. read = parsed
  190. check(read is not None
  191. and read.get("title") == "The GNU Manifesto"
  192. and read.get("total_lines", 0) > 50
  193. and read.get("lines_read") == 20
  194. and "GNU" in (read.get("content") or ""),
  195. "read_canonical_text returns a numbered window with the header")
  196. # The query tool's side call: a FRESH single-turn request (two
  197. # messages, no tools array) hitting the same scripted endpoint;
  198. # only the answer enters the conversation. Tool calls execute in
  199. # order, so the request sequence is: list round, read round, query
  200. # round, the SIDE single-turn call, then the final round.
  201. check(wait_for(lambda: len(llm.requests) >= 5 or False, timeout=15,
  202. what="the query side call") is not None,
  203. "query_canonical_text's side call reaches the endpoint")
  204. side_call = llm.requests[3]
  205. side_messages = side_call.get("messages") or []
  206. check(len(side_messages) == 2
  207. and side_messages[0].get("role") == "system"
  208. and "helpful assistant" in side_messages[0].get("content", "")
  209. and side_messages[1].get("role") == "user"
  210. and "four freedoms" in side_messages[1].get("content", "")
  211. and "free software" in side_messages[1].get("content", "").lower(),
  212. "the side call is single-turn and carries the text, not the chat")
  213. check(not side_call.get("tools"), "the side call declares no tools")
  214. check(wait_status(chat_id, "idle", timeout=30) is not None,
  215. "the turn completes to idle")
  216. final_rows = chat_rows(chat_id, "kind, content, payload")
  217. answers = []
  218. for row in final_rows:
  219. if row[0] != "tool":
  220. continue
  221. result = json.loads(row[2]).get("result") or ""
  222. try:
  223. answers.append(json.loads(result).get("answer", ""))
  224. except ValueError:
  225. pass
  226. check(any("four freedoms" in a or "freedom" in a.lower() for a in answers),
  227. "the query answer is stored as the tool result")
  228. llm.kill()