failproofai 1.0.9-beta.2 → 1.0.10-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +4 -4
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  10. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  11. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  12. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +14 -14
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +14 -14
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +13 -13
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  30. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  32. package/.next/standalone/.next/server/app/index.html +1 -1
  33. package/.next/standalone/.next/server/app/index.rsc +14 -14
  34. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +13 -13
  35. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
  36. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  38. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +14 -14
  41. package/.next/standalone/.next/server/app/policies/page.js +1 -1
  42. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  44. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  45. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  47. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  48. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  49. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  51. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  52. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +8 -8
  55. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  56. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  57. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +1 -1
  58. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +1 -1
  59. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +1 -1
  60. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  61. package/.next/standalone/.next/server/chunks/src_hooks_1eem5a7._.js +1 -1
  62. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__04usis8._.js +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__056wjo4._.js +2 -2
  64. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1ox4w55._.js → [root-of-the-server]__0a2-9ht._.js} +2 -2
  65. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0n0xg95._.js +2 -2
  66. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0rwtwpm._.js +2 -2
  67. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__11mayhe._.js +2 -2
  68. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__15578wp._.js +2 -2
  69. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m_svbe._.js +2 -2
  70. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1pprgri._.js +2 -2
  71. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1q4p5b8._.js +2 -2
  72. package/.next/standalone/.next/server/chunks/ssr/_042cgl1._.js +1 -1
  73. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  74. package/.next/standalone/.next/server/chunks/ssr/_0bqoto4._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/ssr/{_0n6zrjv._.js → _0krq5f0._.js} +3 -3
  76. package/.next/standalone/.next/server/chunks/ssr/_1gb0ifp._.js +2 -2
  77. package/.next/standalone/.next/server/chunks/ssr/{_1pa3k5k._.js → _1n-mym7._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  79. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_policies_page_actions_1sp2-yo.js +2 -2
  80. package/.next/standalone/.next/server/chunks/ssr/app_actions_get-scheduled-audit_ts_0ei9sni._.js +1 -1
  81. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/app_settings_02tf1h4._.js +1 -1
  85. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  86. package/.next/standalone/.next/server/pages/404.html +1 -1
  87. package/.next/standalone/.next/server/pages/500.html +1 -1
  88. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  89. package/.next/standalone/.next/server/server-reference-manifest.json +23 -23
  90. package/.next/standalone/.next/static/chunks/0s4cko_weh6pi.js +1 -0
  91. package/.next/standalone/.next/static/chunks/0yclxmfppc_8e.js +1 -0
  92. package/.next/standalone/.next/static/chunks/{30z-240_lrczt.js → 1-k37ijadiv9h.js} +1 -1
  93. package/.next/standalone/.next/static/chunks/{28cmd5molgwa3.js → 18reoqya39rov.js} +1 -1
  94. package/.next/standalone/.next/static/chunks/{2b59i-n9l-4of.js → 2cvvec76j9bwq.js} +1 -1
  95. package/.next/standalone/.next/static/chunks/2o9-3ff26v4yd.js +1 -0
  96. package/.next/standalone/.next/static/chunks/3q8vesnwk_r0i.js +1 -0
  97. package/.next/standalone/.next/static/chunks/3tj1zi4xv-bll.js +1 -0
  98. package/.next/standalone/.next/static/chunks/3vk6jdkxvb7yk.js +6 -0
  99. package/.next/standalone/.next/static/chunks/{1srecm9k-z_i1.js → 3z7mx6me_j7d3.js} +1 -1
  100. package/.next/standalone/.next/static/chunks/{2hg_bp8z_vm2g.js → 3ztf2h_fv6_t9.js} +1 -1
  101. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +4 -0
  102. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +29 -4
  103. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +57 -14
  104. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +17 -3
  105. package/.next/standalone/fp-cloud-cli/tests/test_request_id.py +111 -0
  106. package/.next/standalone/fp-cloud-cli/uv.lock +3 -3
  107. package/.next/standalone/hermes-plugin/README.md +83 -10
  108. package/.next/standalone/hermes-plugin/__init__.py +762 -28
  109. package/.next/standalone/hermes-plugin/client.py +4 -2
  110. package/.next/standalone/hermes-plugin/ledger.py +9 -3
  111. package/.next/standalone/hermes-plugin/plugin.yaml +2 -2
  112. package/.next/standalone/package.json +10 -10
  113. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/__init__.py +8 -1
  114. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/client.py +82 -7
  115. package/.next/standalone/sdk/python/failproofai_sdk/evaluator/runtime.py +23 -4
  116. package/.next/standalone/sdk/python/tests/test_evaluator_request_id.py +197 -0
  117. package/.next/standalone/sdk/python/uv.lock +9 -9
  118. package/.next/standalone/sdk/typescript/CHANGELOG.md +39 -0
  119. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package-lock.json +3 -3
  120. package/.next/standalone/sdk/typescript/integration/fixtures/ai-5/package.json +1 -1
  121. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package-lock.json +16 -113
  122. package/.next/standalone/sdk/typescript/integration/fixtures/mastra-0/package.json +3 -1
  123. package/.next/standalone/server.js +1 -1
  124. package/dist/cli.mjs +235 -117
  125. package/dist/worker.mjs +5 -2
  126. package/hermes-plugin/README.md +83 -10
  127. package/hermes-plugin/__init__.py +762 -28
  128. package/hermes-plugin/client.py +4 -2
  129. package/hermes-plugin/ledger.py +9 -3
  130. package/hermes-plugin/plugin.yaml +2 -2
  131. package/package.json +10 -10
  132. package/src/hooks/daemon-service.ts +14 -1
  133. package/src/hooks/integrations.ts +108 -26
  134. package/src/hooks/safe-config-write.ts +80 -7
  135. package/src/hooks/types.ts +7 -0
  136. package/.next/standalone/.next/static/chunks/0h--izcpk__m9.js +0 -1
  137. package/.next/standalone/.next/static/chunks/2k5vhij_w1g82.js +0 -1
  138. package/.next/standalone/.next/static/chunks/2o1fy-waqn1he.js +0 -6
  139. package/.next/standalone/.next/static/chunks/38gpf5tb_rd0m.js +0 -1
  140. package/.next/standalone/.next/static/chunks/3nyf50dub1vuk.js +0 -1
  141. package/.next/standalone/.next/static/chunks/42-l25bbfo0ec.js +0 -1
  142. /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_buildManifest.js +0 -0
  143. /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_clientMiddlewareManifest.js +0 -0
  144. /package/.next/standalone/.next/static/{D1BjwOkHNPetPo47DhV4_ → OJTzlkhxVSWnwhlF2Q7m3}/_ssgManifest.js +0 -0
@@ -19,6 +19,7 @@ Two auth modes, and they never mix:
19
19
  from __future__ import annotations
20
20
 
21
21
  import json as _json
22
+ import re
22
23
  import uuid
23
24
  from dataclasses import dataclass
24
25
  from enum import Enum
@@ -264,8 +265,43 @@ def _path(ctx: ClientContext, path: str) -> str:
264
265
  return path
265
266
 
266
267
 
268
+ # A request id as the server may echo it: our own 32 hex, or a dashed UUID from
269
+ # an older server. Anything else is not put on an error a person will paste.
270
+ _SANE_REQUEST_ID = re.compile(r"^[A-Za-z0-9-]{1,64}$")
271
+
272
+
273
+ def _new_request_id() -> str:
274
+ """One id per HTTP request — a W3C trace id, the shape the server keeps as-is."""
275
+ return uuid.uuid4().hex
276
+
277
+
278
+ def _request_id_of(response: httpx.Response) -> Optional[str]:
279
+ """The failed request's id: the server's echo, else the one this CLI sent.
280
+
281
+ The server echoes ours back, so the two normally agree. The fallback matters
282
+ when something in front of the API answered instead — a proxy, a front door —
283
+ and a ref is still worth printing: our id is in the dashboard's access logs.
284
+ """
285
+ echoed = (response.headers.get("x-request-id") or "").strip()
286
+ if _SANE_REQUEST_ID.match(echoed):
287
+ return echoed
288
+ try:
289
+ return response.request.headers.get("x-request-id")
290
+ except RuntimeError: # a Response built without a request (tests)
291
+ return None
292
+
293
+
294
+ def _sent_request_id(exc: httpx.RequestError) -> Optional[str]:
295
+ """The id of a request that never got an answer."""
296
+ try:
297
+ return exc.request.headers.get("x-request-id")
298
+ except RuntimeError:
299
+ return None
300
+
301
+
267
302
  def _client(ctx: ClientContext, *, timeout: Any = None) -> httpx.Client:
268
- headers = {"x-request-id": uuid.uuid4().hex}
303
+ # One id per client, and every call site opens a fresh client per request.
304
+ headers = {"x-request-id": _new_request_id()}
269
305
  cookies = None
270
306
  # Bearer XOR cookie — an `else`, never two independent `if`s. Sending both
271
307
  # would hand a human's `ae_session` to `/v1` alongside the key, and every
@@ -355,7 +391,7 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
355
391
  "the dashboard's login page, so it landed on the web app instead of "
356
392
  "the API.",
357
393
  status=response.status_code,
358
- request_id=response.headers.get("x-request-id"),
394
+ request_id=_request_id_of(response),
359
395
  hint="point --base-url at the server itself, e.g. http://localhost:8080",
360
396
  )
361
397
  # Session mode is deliberately left alone for the /login case: that 3xx is
@@ -375,22 +411,23 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
375
411
  "The request was redirected and did not reach the API, so it had no "
376
412
  "effect. Nothing was changed.",
377
413
  status=response.status_code,
378
- request_id=response.headers.get("x-request-id"),
414
+ request_id=_request_id_of(response),
379
415
  hint=(
380
416
  "check --base-url: a redirect here usually means http:// where the "
381
417
  "server wants https://, or a front door in front of the API"
382
418
  ),
383
419
  )
384
420
  return
385
- request_id = response.headers.get("x-request-id")
421
+ request_id = _request_id_of(response)
386
422
  message = _extract_error(response)
387
423
  if response.status_code == 401:
388
424
  if key_mode:
389
425
  raise AuthError(
390
426
  "The API key was rejected. It may be revoked, mistyped, or issued by a "
391
- "different deployment than --base-url points at."
427
+ "different deployment than --base-url points at.",
428
+ request_id=request_id,
392
429
  )
393
- raise AuthError("Session expired or not logged in. Run fp login.")
430
+ raise AuthError("Session expired or not logged in. Run fp login.", request_id=request_id)
394
431
  if response.status_code == 403:
395
432
  needed = _required_permission(response)
396
433
  if key_mode:
@@ -406,10 +443,13 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
406
443
  raise ForbiddenError(
407
444
  f"{what}, or it cannot act for this org — the server answers 403 for both.",
408
445
  hint="check the key's grants, and the org you targeted with --org / FP_ORG",
446
+ request_id=request_id,
409
447
  )
410
448
  if needed:
411
- raise ForbiddenError(f"you don't have the {needed} permission")
412
- raise ForbiddenError(message or "you don't have permission for this action")
449
+ raise ForbiddenError(f"you don't have the {needed} permission", request_id=request_id)
450
+ raise ForbiddenError(
451
+ message or "you don't have permission for this action", request_id=request_id
452
+ )
413
453
  if response.status_code == 404:
414
454
  # In key mode a 404 has TWO very different causes, and the wrong reading
415
455
  # sends people hunting for a server bug that isn't there:
@@ -434,7 +474,7 @@ def _raise_for_status(response: httpx.Response, ctx: ClientContext) -> None:
434
474
  request_id=request_id,
435
475
  hint="point --base-url at the server itself, e.g. http://localhost:8080",
436
476
  )
437
- raise NotFoundError(message or "Not found.")
477
+ raise NotFoundError(message or "Not found.", request_id=request_id)
438
478
  if response.status_code == 429:
439
479
  retry_after = response.headers.get("retry-after")
440
480
  wait = f" Retry after {retry_after}s." if retry_after else " Please wait a moment and try again."
@@ -458,7 +498,8 @@ def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] =
458
498
  response = client.get(url, params=clean)
459
499
  except httpx.RequestError as exc:
460
500
  raise NetworkError(
461
- f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
501
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
502
+ request_id=_sent_request_id(exc),
462
503
  )
463
504
  _raise_for_status(response, ctx)
464
505
  # A 2xx with an empty or non-JSON body is anomalous for a read (e.g. a proxy or
@@ -470,7 +511,7 @@ def _get_json(ctx: ClientContext, path: str, params: Optional[Dict[str, Any]] =
470
511
  raise ApiError(
471
512
  "The dashboard returned a malformed (non-JSON) response.",
472
513
  status=response.status_code,
473
- request_id=response.headers.get("x-request-id"),
514
+ request_id=_request_id_of(response),
474
515
  )
475
516
 
476
517
 
@@ -495,7 +536,8 @@ def _request_json(
495
536
  response = client.request(method, url, json=json_body, params=clean)
496
537
  except httpx.RequestError as exc:
497
538
  raise NetworkError(
498
- f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
539
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
540
+ request_id=_sent_request_id(exc),
499
541
  )
500
542
  _raise_for_status(response, ctx)
501
543
  # A genuinely empty body (204, or a 200 with no content) is a legitimate
@@ -514,7 +556,7 @@ def _request_json(
514
556
  "The dashboard returned a malformed (non-JSON) response, so the request "
515
557
  "may not have been applied.",
516
558
  status=response.status_code,
517
- request_id=response.headers.get("x-request-id"),
559
+ request_id=_request_id_of(response),
518
560
  )
519
561
 
520
562
 
@@ -575,7 +617,8 @@ def org_is_accessible(ctx: ClientContext, slug: str) -> bool:
575
617
  response = client.get(_path(probe, "/api/access-granters"))
576
618
  except httpx.RequestError as exc:
577
619
  raise NetworkError(
578
- f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}"
620
+ f"Cannot reach FailproofAI Cloud at {ctx.base_url}: {exc}",
621
+ request_id=_sent_request_id(exc),
579
622
  )
580
623
  if response.status_code == 200:
581
624
  return True
@@ -26,9 +26,24 @@ class FpCliError(click.ClickException):
26
26
 
27
27
  exit_code = 1
28
28
 
29
- def __init__(self, message: str, *, hint: Optional[str] = None) -> None:
29
+ def __init__(
30
+ self,
31
+ message: str,
32
+ *,
33
+ hint: Optional[str] = None,
34
+ request_id: Optional[str] = None,
35
+ ) -> None:
30
36
  super().__init__(message)
31
37
  self.hint = hint
38
+ # The id of the HTTP request that failed — the server's echo, else the
39
+ # one this CLI sent. On every typed error, not only ApiError, so a 403 or
40
+ # a 404 is as traceable in the server's logs as a 500.
41
+ self.request_id = request_id
42
+
43
+ @property
44
+ def ref(self) -> Optional[str]:
45
+ """What a person reads and pastes: the first 8 chars of the request id."""
46
+ return self.request_id[:8] if self.request_id else None
32
47
 
33
48
 
34
49
  class KeyModeUnsupportedError(FpCliError):
@@ -85,9 +100,8 @@ class ApiError(FpCliError):
85
100
  request_id: Optional[str] = None,
86
101
  hint: Optional[str] = None,
87
102
  ) -> None:
88
- super().__init__(message, hint=hint)
103
+ super().__init__(message, hint=hint, request_id=request_id)
89
104
  self.status = status
90
- self.request_id = request_id
91
105
 
92
106
  def format_message(self) -> str:
93
107
  parts = [self.message]
@@ -0,0 +1,111 @@
1
+ """Every CLI request carries its own id, and every failure names it.
2
+
3
+ A customer pastes `ref 4bf92f35`; support finds every server log line of that
4
+ request with one query. That only works if (a) each HTTP request has a distinct
5
+ id, (b) every typed error — not just ApiError — carries it, and (c) the id is
6
+ still there when something in front of the API answered without echoing one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+
14
+ import httpx
15
+ import pytest
16
+ import respx
17
+
18
+ from fp_cli import client as api
19
+ from fp_cli.app import app
20
+ from fp_cli.client import ClientContext
21
+ from fp_cli.errors import ApiError, AuthError, ForbiddenError, NetworkError, NotFoundError
22
+
23
+ BASE = "http://dash.test"
24
+ TRACE_ID = re.compile(r"^[0-9a-f]{32}$")
25
+ SERVER_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
26
+
27
+
28
+ def ctx() -> ClientContext:
29
+ return ClientContext(base_url=BASE, token="tok")
30
+
31
+
32
+ @respx.mock
33
+ def test_each_request_gets_its_own_trace_id():
34
+ route = respx.get(f"{BASE}/api/sessions").mock(
35
+ return_value=httpx.Response(200, json={"sessions": [], "next_cursor": None})
36
+ )
37
+ api.list_sessions(ctx())
38
+ api.list_sessions(ctx())
39
+ ids = [call.request.headers["x-request-id"] for call in route.calls]
40
+ assert len(ids) == 2
41
+ assert all(TRACE_ID.match(i) for i in ids), ids
42
+ assert ids[0] != ids[1]
43
+
44
+
45
+ @pytest.mark.parametrize(
46
+ ("status", "error_type"),
47
+ [(401, AuthError), (403, ForbiddenError), (404, NotFoundError), (500, ApiError)],
48
+ )
49
+ @respx.mock
50
+ def test_every_typed_error_carries_the_servers_request_id(status, error_type):
51
+ respx.get(f"{BASE}/api/sessions").mock(
52
+ return_value=httpx.Response(status, json={"error": "x"}, headers={"x-request-id": SERVER_ID})
53
+ )
54
+ with pytest.raises(error_type) as excinfo:
55
+ api.list_sessions(ctx())
56
+ assert excinfo.value.request_id == SERVER_ID
57
+ assert excinfo.value.ref == SERVER_ID[:8]
58
+
59
+
60
+ @respx.mock
61
+ def test_without_an_echo_the_error_names_the_id_we_sent():
62
+ # A proxy or front door answered: no x-request-id on the response. The id we
63
+ # sent is still the one in the dashboard's access logs.
64
+ route = respx.get(f"{BASE}/api/sessions").mock(return_value=httpx.Response(502, text="bad gateway"))
65
+ with pytest.raises(ApiError) as excinfo:
66
+ api.list_sessions(ctx())
67
+ sent = route.calls.last.request.headers["x-request-id"]
68
+ assert excinfo.value.request_id == sent
69
+
70
+
71
+ @respx.mock
72
+ def test_an_unsane_echo_is_not_put_on_the_error():
73
+ route = respx.get(f"{BASE}/api/sessions").mock(
74
+ return_value=httpx.Response(500, json={"error": "x"}, headers={"x-request-id": "a b c"})
75
+ )
76
+ with pytest.raises(ApiError) as excinfo:
77
+ api.list_sessions(ctx())
78
+ assert excinfo.value.request_id == route.calls.last.request.headers["x-request-id"]
79
+
80
+
81
+ @respx.mock
82
+ def test_a_network_error_names_the_id_it_sent():
83
+ route = respx.get(f"{BASE}/api/sessions").mock(side_effect=httpx.ConnectError("refused"))
84
+ with pytest.raises(NetworkError) as excinfo:
85
+ api.list_sessions(ctx())
86
+ assert excinfo.value.request_id == route.calls.last.request.headers["x-request-id"]
87
+ # Exit code contract unchanged.
88
+ assert excinfo.value.exit_code == 3
89
+
90
+
91
+ @respx.mock
92
+ def test_the_human_error_line_shows_the_ref(logged_in, runner):
93
+ respx.get(f"{BASE}/api/sessions").mock(
94
+ return_value=httpx.Response(403, json={"error": "forbidden"}, headers={"x-request-id": SERVER_ID})
95
+ )
96
+ result = runner.invoke(app, ["sessions"])
97
+ assert result.exit_code == 5
98
+ assert f"ref {SERVER_ID[:8]}" in result.stderr
99
+
100
+
101
+ @respx.mock
102
+ def test_the_json_envelope_carries_the_full_id_for_non_api_errors(logged_in, runner):
103
+ respx.get(f"{BASE}/api/sessions").mock(
104
+ return_value=httpx.Response(403, json={"error": "forbidden"}, headers={"x-request-id": SERVER_ID})
105
+ )
106
+ result = runner.invoke(app, ["--json", "sessions"])
107
+ assert result.exit_code == 5
108
+ data = json.loads(result.stdout)
109
+ assert data["request_id"] == SERVER_ID
110
+ # The ref is for humans; the envelope's message stays clean.
111
+ assert "ref " not in data["error"]
@@ -541,9 +541,9 @@ wheels = [
541
541
 
542
542
  [[package]]
543
543
  name = "urllib3"
544
- version = "2.7.0"
544
+ version = "2.8.0"
545
545
  source = { registry = "https://pypi.org/simple" }
546
- sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
546
+ sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
547
547
  wheels = [
548
- { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
548
+ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
549
549
  ]
@@ -7,6 +7,11 @@ translates structured verdicts into Hermes-native hook behavior.
7
7
  Validated contract: Hermes 0.21.3 at
8
8
  `4d55ca91656ac5f83e1506679b7f81e0238e5e16`. The plugin uses only documented
9
9
  manifest v2 fields, `PluginContext` methods/state, and keyword hook payloads.
10
+ Hermes 0.20.0 is also supported: its `PluginContext` has no `get_config` or
11
+ `state` (both arrived in 0.20.1), so the plugin reads the same
12
+ `plugins.entries.failproofai` settings through Hermes' config loader and keeps
13
+ its ledger in the directory `state.data_dir` would name, which a later Hermes
14
+ upgrade reuses.
10
15
 
11
16
  ## Installation
12
17
 
@@ -40,6 +45,14 @@ Legacy FailproofAI shell hooks are removed during migration. Operator-owned
40
45
  hooks and unrelated plugin settings are preserved. No dashboard deployment or
41
46
  backtest is part of installation.
42
47
 
48
+ ## Supported Hermes versions
49
+
50
+ Hermes 0.20.0 and later. Tested on 0.20.0 (v2026.8.3), 0.20.1 (v2026.8.13),
51
+ 0.20.6 (v2026.8.27), 0.21.0 (v2026.8.31), 0.21.3 (v2026.9.14), 0.21.5
52
+ (v2026.9.24, the latest release) and upstream main (2026-10-05).
53
+ Hermes 0.20.0 has no `ctx.get_config` or `ctx.state`; the plugin detects that and
54
+ reads the same settings and state directory another way.
55
+
43
56
  ## Runtime path
44
57
 
45
58
  ```text
@@ -49,6 +62,8 @@ Hermes pre_tool_call
49
62
  -> warm TypeScript policy worker
50
63
  -> structured allow | deny | instruct verdict
51
64
  -> Hermes-native return value
65
+ Hermes tool_execution middleware (wraps the real call)
66
+ -> instruct reminder placed at the start of the call's own result
52
67
  ```
53
68
 
54
69
  There is no cloud request and no new CLI process in the tool-call path.
@@ -57,16 +72,67 @@ There is no cloud request and no new CLI process in the tool-call path.
57
72
 
58
73
  - `allow`: return no directive; Hermes runs the tool.
59
74
  - `deny`: always return `{"action":"block","message":"..."}`.
60
- - `instruct`: persist delivery state, block the first attempt with a
61
- model-visible `FAILPROOF INSTRUCTION`, keep the same API request blocked, then
62
- allow a later API iteration for the same instruction scope.
63
-
64
- The default instruction scope is profile + session + task + turn + policy
65
- fingerprint + canonical tool. The persistent SQLite ledger lives below Hermes'
66
- profile-scoped plugin data directory. Two distinct instruction interruptions
67
- are allowed per turn by default; after that, advisory instructions fail open so
68
- they cannot create an infinite retry loop. A real deny is never bypassed by the
69
- ledger.
75
+ - `instruct`: the call runs, and the reminder is placed at the **start** of its
76
+ result, so it survives the 1,500-character preview Hermes keeps of a large
77
+ result:
78
+ - a JSON object result gets `"failproof_policy_reminder"` as its first key
79
+ (the rest is unchanged, so `json.loads` consumers such as `execute_code`
80
+ scripts keep working). A field of that name the tool returned itself is
81
+ moved to `"tool_failproof_policy_reminder"`, never merged into the
82
+ reminder;
83
+ - other text gets a leading `[FailproofAI policy reminder (<policies>)] ...`
84
+ line (an `Error...` result keeps that prefix and gets the line at its end);
85
+ - a list of content blocks or a multimodal envelope gets a leading text block,
86
+ leaving image blocks intact.
87
+
88
+ Tool calls made by an `execute_code` script hand their reminder to the outer
89
+ `execute_code` result, which is the one the model reads, and the script gets
90
+ its own result untouched, so nothing it prints, writes or sends carries the
91
+ reminder. (With no open outer call to carry it, the inner result keeps it.)
92
+ A result Hermes reports as blocked (`{"error": ...}` only) is never
93
+ annotated. This uses Hermes' `tool_execution` middleware
94
+ (`ctx.register_middleware`, present unchanged in 0.20.0, 0.21.x and main).
95
+
96
+ A policy's reminder (same policy and reason) is attached once per Hermes
97
+ session + turn; a later turn gets it again. When the full reminder would take
98
+ the result past 7,500 characters it is shortened to the policy names and the
99
+ first 160 characters of the reason, so it never pushes a result over Hermes'
100
+ 8,000-character persistence threshold and never fills the 1,500-character
101
+ preview of a result already past it.
102
+
103
+ `instruct` therefore no longer stops the action first on Hermes: use `deny`
104
+ when an action must not happen at all.
105
+
106
+ Where Hermes offers no `tool_execution` middleware, `instruct` falls back to
107
+ holding the call once: persist delivery state, return model-visible
108
+ `FailproofAI policy guidance (...)` ("apply it, then continue; repeating the
109
+ same call is allowed"), keep the same API request held, then allow a later API
110
+ iteration. That fallback's scope is profile + session + task + turn + policy
111
+ fingerprint — not the tool, so moving the same work to another tool (for
112
+ example `execute_code`) is not held again.
113
+ The persistent SQLite ledger lives below Hermes' profile-scoped plugin data
114
+ directory. Two distinct instruction interruptions are allowed per turn by
115
+ default; after that, advisory instructions fail open so they cannot create an
116
+ infinite retry loop. A real deny is never bypassed by the ledger.
117
+
118
+ Every model turn also receives a short protocol note (`pre_llm_call`): a
119
+ `failproof_policy_reminder` is an operator rule added by FailproofAI (not tool
120
+ data) about a call that ran normally and is never to be quoted, repeated or
121
+ mentioned in replies, messages or files, a FailproofAI block means that action
122
+ must not run by any route, and `FailproofAI policy guidance` held a call only once.
123
+
124
+ No hook raises: `pre_tool_call` blocks on any internal error (Hermes 0.20.0 and
125
+ 0.21.x would treat a raised exception as allow), observer hooks log and return,
126
+ and the middleware falls back to the plain result. One evaluation, connect
127
+ included, is capped at 25 seconds (`evaluation_timeout_ms`, default 12000):
128
+ Hermes 0.21.x abandons a `pre_tool_call` after 30 seconds and then blocks every
129
+ call for 60. Observer hooks wait at most 2 seconds, so against a hung daemon a
130
+ blocked call costs the evaluation deadline plus 2 seconds, not twice the
131
+ deadline.
132
+
133
+ Tool names are canonicalized in the daemon: Hermes 0.21's `process_manage`,
134
+ `cronjob_manage` and `todo_list` match policies written for 0.20's `process`,
135
+ `cronjob` and `todo`.
70
136
 
71
137
  ## Configuration
72
138
 
@@ -137,6 +203,13 @@ hermes plugins doctor ~/.hermes/plugins/failproofai --ci
137
203
  hermes logs --level WARNING
138
204
  ```
139
205
 
206
+ Each `register()` writes `heartbeat.json` beside `instructions.db` in the
207
+ profile's plugin data directory (`plugin-data/agent-plugin-failproofai-5296f299/`):
208
+ pid, Hermes version, `HERMES_HOME`, profile, the plugin's real path and version,
209
+ the hooks and middleware Hermes accepted, `register_ok` and a timestamp. It is
210
+ the proof of what a running Hermes process actually loaded, which
211
+ `hermes plugins list` (config only) cannot show; Hermes' log gets one INFO line.
212
+
140
213
  Use a temporary `HERMES_HOME` for development and compatibility tests. Remove
141
214
  the integration with:
142
215