canon.py 14 KB

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