canon.py 14 KB

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