claude-smart 0.2.41 → 0.2.43

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 (396) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/README.md +1 -1
  3. package/bin/claude-smart.js +86 -48
  4. package/package.json +10 -3
  5. package/plugin/.claude-plugin/plugin.json +9 -3
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +2 -2
  8. package/plugin/dashboard/next.config.ts +9 -1
  9. package/plugin/pyproject.toml +2 -2
  10. package/plugin/scripts/_lib.sh +91 -0
  11. package/plugin/scripts/backend-service.sh +46 -15
  12. package/plugin/scripts/cli.sh +29 -1
  13. package/plugin/scripts/codex-hook.js +72 -4
  14. package/plugin/scripts/dashboard-build.sh +1 -0
  15. package/plugin/scripts/dashboard-service.sh +1 -0
  16. package/plugin/scripts/ensure-plugin-root.sh +7 -14
  17. package/plugin/scripts/hook_entry.sh +1 -0
  18. package/plugin/scripts/smart-install.sh +18 -2
  19. package/plugin/src/claude_smart/cli.py +72 -38
  20. package/plugin/src/claude_smart/context_format.py +11 -12
  21. package/plugin/src/claude_smart/cs_cite.py +26 -12
  22. package/plugin/src/claude_smart/ids.py +13 -5
  23. package/plugin/uv.lock +1 -1
  24. package/plugin/vendor/reflexio/.env.example +53 -0
  25. package/plugin/vendor/reflexio/LICENSE +201 -0
  26. package/plugin/vendor/reflexio/README.md +338 -0
  27. package/plugin/vendor/reflexio/pyproject.toml +271 -0
  28. package/plugin/vendor/reflexio/reflexio/README.md +184 -0
  29. package/plugin/vendor/reflexio/reflexio/__init__.py +166 -0
  30. package/plugin/vendor/reflexio/reflexio/benchmarks/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/README.md +109 -0
  32. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/__init__.py +1 -0
  33. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/backends.py +175 -0
  34. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/bench.py +642 -0
  35. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/embed_cache.py +330 -0
  36. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/report.py +317 -0
  37. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/report.md +43 -0
  38. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/results/results.json +4478 -0
  39. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/scenarios.py +134 -0
  40. package/plugin/vendor/reflexio/reflexio/benchmarks/retrieval_latency/seed.py +255 -0
  41. package/plugin/vendor/reflexio/reflexio/cli/README.md +287 -0
  42. package/plugin/vendor/reflexio/reflexio/cli/__init__.py +0 -0
  43. package/plugin/vendor/reflexio/reflexio/cli/__main__.py +56 -0
  44. package/plugin/vendor/reflexio/reflexio/cli/_client.py +86 -0
  45. package/plugin/vendor/reflexio/reflexio/cli/app.py +127 -0
  46. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +266 -0
  47. package/plugin/vendor/reflexio/reflexio/cli/codex_auth.py +503 -0
  48. package/plugin/vendor/reflexio/reflexio/cli/commands/__init__.py +0 -0
  49. package/plugin/vendor/reflexio/reflexio/cli/commands/admin_cmd.py +65 -0
  50. package/plugin/vendor/reflexio/reflexio/cli/commands/agent_playbooks.py +503 -0
  51. package/plugin/vendor/reflexio/reflexio/cli/commands/api.py +114 -0
  52. package/plugin/vendor/reflexio/reflexio/cli/commands/auth.py +109 -0
  53. package/plugin/vendor/reflexio/reflexio/cli/commands/config_cmd.py +511 -0
  54. package/plugin/vendor/reflexio/reflexio/cli/commands/doctor.py +127 -0
  55. package/plugin/vendor/reflexio/reflexio/cli/commands/embeddings.py +53 -0
  56. package/plugin/vendor/reflexio/reflexio/cli/commands/interactions.py +478 -0
  57. package/plugin/vendor/reflexio/reflexio/cli/commands/profiles.py +303 -0
  58. package/plugin/vendor/reflexio/reflexio/cli/commands/services.py +289 -0
  59. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +961 -0
  60. package/plugin/vendor/reflexio/reflexio/cli/commands/shortcuts.py +285 -0
  61. package/plugin/vendor/reflexio/reflexio/cli/commands/status_cmd.py +143 -0
  62. package/plugin/vendor/reflexio/reflexio/cli/commands/user_playbooks.py +373 -0
  63. package/plugin/vendor/reflexio/reflexio/cli/env_loader.py +284 -0
  64. package/plugin/vendor/reflexio/reflexio/cli/errors.py +217 -0
  65. package/plugin/vendor/reflexio/reflexio/cli/log_format.py +247 -0
  66. package/plugin/vendor/reflexio/reflexio/cli/output.py +867 -0
  67. package/plugin/vendor/reflexio/reflexio/cli/paths.py +41 -0
  68. package/plugin/vendor/reflexio/reflexio/cli/run_services.py +391 -0
  69. package/plugin/vendor/reflexio/reflexio/cli/state.py +204 -0
  70. package/plugin/vendor/reflexio/reflexio/cli/stop_services.py +96 -0
  71. package/plugin/vendor/reflexio/reflexio/cli/utils.py +329 -0
  72. package/plugin/vendor/reflexio/reflexio/client/__init__.py +3 -0
  73. package/plugin/vendor/reflexio/reflexio/client/cache.py +150 -0
  74. package/plugin/vendor/reflexio/reflexio/client/client.py +2613 -0
  75. package/plugin/vendor/reflexio/reflexio/defaults.py +23 -0
  76. package/plugin/vendor/reflexio/reflexio/integrations/__init__.py +0 -0
  77. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/.clawhubignore +7 -0
  78. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/README.md +274 -0
  79. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/TESTING.md +517 -0
  80. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/hook/handler.js +473 -0
  81. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package-lock.json +2156 -0
  82. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/package.json +18 -0
  83. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/handler.ts +241 -0
  84. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/hook/setup.ts +140 -0
  85. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/index.ts +130 -0
  86. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/publish.ts +113 -0
  87. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/search.ts +52 -0
  88. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/server.ts +103 -0
  89. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/sqlite-buffer.ts +156 -0
  90. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/lib/user-id.ts +134 -0
  91. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/openclaw.plugin.json +41 -0
  92. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/package.json +17 -0
  93. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/rules/reflexio.md +24 -0
  94. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/plugin/skills/reflexio/SKILL.md +48 -0
  95. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/publish_clawhub.sh +278 -0
  96. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/references/HOOK.md +164 -0
  97. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/install.sh +36 -0
  98. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/scripts/uninstall.sh +35 -0
  99. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/publish.test.ts +27 -0
  100. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/search.test.ts +31 -0
  101. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/server.test.ts +42 -0
  102. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/setup.test.ts +49 -0
  103. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/sqlite-buffer.test.ts +91 -0
  104. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tests/user-id.test.ts +50 -0
  105. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/tsconfig.json +16 -0
  106. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/types/openclaw.d.ts +230 -0
  107. package/plugin/vendor/reflexio/reflexio/integrations/openclaw/vitest.config.ts +13 -0
  108. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/README.md +120 -0
  109. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/TESTING.md +168 -0
  110. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package-lock.json +1657 -0
  111. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/package.json +16 -0
  112. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/HEARTBEAT.md +6 -0
  113. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/README.md +84 -0
  114. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/SKILL.md +194 -0
  115. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/_meta.json +6 -0
  116. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/agents/reflexio-extractor.md +45 -0
  117. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/handler.ts +214 -0
  118. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/hook/setup.ts +55 -0
  119. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/index.ts +327 -0
  120. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/consolidate.ts +233 -0
  121. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/dedup.ts +80 -0
  122. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/io.ts +155 -0
  123. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/openclaw-cli.ts +67 -0
  124. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/search.ts +33 -0
  125. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-playbook.ts +76 -0
  126. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/lib/write-profile.ts +79 -0
  127. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/openclaw.plugin.json +46 -0
  128. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/package.json +18 -0
  129. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/README.md +36 -0
  130. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/full_consolidation.md +56 -0
  131. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/playbook_extraction.md +217 -0
  132. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/prompts/profile_extraction.md +132 -0
  133. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-consolidate/SKILL.md +33 -0
  134. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/plugin/skills/reflexio-embedded/SKILL.md +194 -0
  135. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/HOOK.md +18 -0
  136. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/architecture.md +49 -0
  137. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/comparison.md +31 -0
  138. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/future-work.md +47 -0
  139. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/references/porting-notes.md +52 -0
  140. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/install.sh +52 -0
  141. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/scripts/uninstall.sh +36 -0
  142. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/consolidate.test.ts +135 -0
  143. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/dedup.test.ts +104 -0
  144. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/io.test.ts +175 -0
  145. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/search.test.ts +66 -0
  146. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/smoke-test.ts +140 -0
  147. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-playbook.test.ts +93 -0
  148. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tests/write-profile.test.ts +174 -0
  149. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/tsconfig.json +16 -0
  150. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/types/openclaw.d.ts +230 -0
  151. package/plugin/vendor/reflexio/reflexio/integrations/openclaw-embedded/vitest.config.ts +7 -0
  152. package/plugin/vendor/reflexio/reflexio/lib/__init__.py +23 -0
  153. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +310 -0
  154. package/plugin/vendor/reflexio/reflexio/lib/_base.py +225 -0
  155. package/plugin/vendor/reflexio/reflexio/lib/_config.py +83 -0
  156. package/plugin/vendor/reflexio/reflexio/lib/_dashboard.py +266 -0
  157. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +176 -0
  158. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +334 -0
  159. package/plugin/vendor/reflexio/reflexio/lib/_operations.py +153 -0
  160. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +545 -0
  161. package/plugin/vendor/reflexio/reflexio/lib/_reflection.py +52 -0
  162. package/plugin/vendor/reflexio/reflexio/lib/_search.py +167 -0
  163. package/plugin/vendor/reflexio/reflexio/lib/_storage_labels.py +103 -0
  164. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +288 -0
  165. package/plugin/vendor/reflexio/reflexio/lib/reflexio_lib.py +27 -0
  166. package/plugin/vendor/reflexio/reflexio/models/__init__.py +0 -0
  167. package/plugin/vendor/reflexio/reflexio/models/api_schema/__init__.py +0 -0
  168. package/plugin/vendor/reflexio/reflexio/models/api_schema/braintrust_schema.py +141 -0
  169. package/plugin/vendor/reflexio/reflexio/models/api_schema/common.py +41 -0
  170. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +3 -0
  171. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +1103 -0
  172. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +63 -0
  173. package/plugin/vendor/reflexio/reflexio/models/api_schema/eval_overview_schema.py +487 -0
  174. package/plugin/vendor/reflexio/reflexio/models/api_schema/internal_schema.py +28 -0
  175. package/plugin/vendor/reflexio/reflexio/models/api_schema/pending_tool_call_schema.py +83 -0
  176. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +766 -0
  177. package/plugin/vendor/reflexio/reflexio/models/api_schema/service_schemas.py +9 -0
  178. package/plugin/vendor/reflexio/reflexio/models/api_schema/stall_state_schema.py +32 -0
  179. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/__init__.py +3 -0
  180. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/converters.py +177 -0
  181. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/entities.py +129 -0
  182. package/plugin/vendor/reflexio/reflexio/models/api_schema/ui/enums.py +25 -0
  183. package/plugin/vendor/reflexio/reflexio/models/api_schema/validators.py +280 -0
  184. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +908 -0
  185. package/plugin/vendor/reflexio/reflexio/models/py.typed +0 -0
  186. package/plugin/vendor/reflexio/reflexio/server/OVERVIEW.md +90 -0
  187. package/plugin/vendor/reflexio/reflexio/server/README.md +616 -0
  188. package/plugin/vendor/reflexio/reflexio/server/__init__.py +210 -0
  189. package/plugin/vendor/reflexio/reflexio/server/__main__.py +132 -0
  190. package/plugin/vendor/reflexio/reflexio/server/_auth.py +25 -0
  191. package/plugin/vendor/reflexio/reflexio/server/api.py +2714 -0
  192. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/account_api.py +143 -0
  193. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/health_api.py +91 -0
  194. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/pending_tool_call_api.py +572 -0
  195. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/precondition_checks.py +66 -0
  196. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +540 -0
  197. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +50 -0
  198. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/stall_state_api.py +100 -0
  199. package/plugin/vendor/reflexio/reflexio/server/cache/__init__.py +15 -0
  200. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +208 -0
  201. package/plugin/vendor/reflexio/reflexio/server/correlation.py +46 -0
  202. package/plugin/vendor/reflexio/reflexio/server/llm/__init__.py +30 -0
  203. package/plugin/vendor/reflexio/reflexio/server/llm/embedding_service.py +110 -0
  204. package/plugin/vendor/reflexio/reflexio/server/llm/image_utils.py +55 -0
  205. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +1595 -0
  206. package/plugin/vendor/reflexio/reflexio/server/llm/llm_utils.py +112 -0
  207. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +469 -0
  208. package/plugin/vendor/reflexio/reflexio/server/llm/providers/__init__.py +1 -0
  209. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1122 -0
  210. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_stream_parser.py +197 -0
  211. package/plugin/vendor/reflexio/reflexio/server/llm/providers/embedding_service_provider.py +210 -0
  212. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +213 -0
  213. package/plugin/vendor/reflexio/reflexio/server/llm/providers/nomic_embedding_provider.py +255 -0
  214. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/__init__.py +6 -0
  215. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +177 -0
  216. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/llm_reranker.py +148 -0
  217. package/plugin/vendor/reflexio/reflexio/server/llm/tools.py +699 -0
  218. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +179 -0
  219. package/plugin/vendor/reflexio/reflexio/server/prompt/__init__.py +0 -0
  220. package/plugin/vendor/reflexio/reflexio/server/prompt/_dispatchers.py +54 -0
  221. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/README.md +121 -0
  222. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation/v1.0.0.prompt.md +58 -0
  223. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/agent_success_evaluation_with_comparison/v1.0.0.prompt.md +76 -0
  224. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/answer_synthesis/v1.5.2.prompt.md +88 -0
  225. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/compress_session_for_query/v1.3.0.prompt.md +31 -0
  226. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/document_expansion/v1.0.0.prompt.md +20 -0
  227. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.0.0.prompt.md +53 -0
  228. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.1.0.prompt.md +57 -0
  229. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.2.0.prompt.md +68 -0
  230. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.3.0.prompt.md +70 -0
  231. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.4.0.prompt.md +77 -0
  232. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.5.0.prompt.md +82 -0
  233. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/memory_reflection/v1.6.0.prompt.md +83 -0
  234. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.1.0.prompt.md +193 -0
  235. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_aggregation/v2.2.0.prompt.md +206 -0
  236. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0-deprecated.prompt.md +66 -0
  237. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.0.0.prompt.md +43 -0
  238. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v1.1.0.prompt.md +46 -0
  239. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0-deprecated.prompt.md +64 -0
  240. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.0.0.prompt.md +39 -0
  241. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.1.0.prompt.md +39 -0
  242. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.2.0.prompt.md +47 -0
  243. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_consolidation/v2.3.0.prompt.md +58 -0
  244. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.0.2.prompt.md +254 -0
  245. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.1.0.prompt.md +274 -0
  246. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.2.0.prompt.md +279 -0
  247. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v1.0.0.prompt.md +73 -0
  248. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v2.0.0.prompt.md +86 -0
  249. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.0.0.prompt.md +97 -0
  250. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.1.0.prompt.md +119 -0
  251. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.2.0.prompt.md +123 -0
  252. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context_expert/v3.3.0.prompt.md +127 -0
  253. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.0.0.prompt.md +14 -0
  254. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.1.0.prompt.md +24 -0
  255. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main/v1.2.0.prompt.md +29 -0
  256. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.0.0.prompt.md +11 -0
  257. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.1.0.prompt.md +21 -0
  258. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_main_expert/v1.2.0.prompt.md +25 -0
  259. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.0.0.prompt.md +37 -0
  260. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.1.0.prompt.md +40 -0
  261. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_optimizer_judge/v1.2.0.prompt.md +36 -0
  262. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v1.0.0.prompt.md +45 -0
  263. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v2.0.0.prompt.md +81 -0
  264. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate/v3.0.0.prompt.md +80 -0
  265. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_should_generate_expert/v1.0.0.prompt.md +34 -0
  266. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_deduplication/v1.0.0.prompt.md +116 -0
  267. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate/v1.0.0.prompt.md +33 -0
  268. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_should_generate_override/v1.0.0.prompt.md +16 -0
  269. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.0.0.prompt.md +140 -0
  270. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_instruction_start/v1.1.0.prompt.md +160 -0
  271. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/profile_update_main/v1.0.0.prompt.md +14 -0
  272. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/query_reformulation/v1.0.0.prompt.md +19 -0
  273. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/rerank_relevance/v1.1.0.prompt.md +44 -0
  274. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_comparison/v1.0.0.prompt.md +43 -0
  275. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/shadow_content_evaluation/v1.0.0.prompt.md +33 -0
  276. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/feedback_extraction_main_v1.jsonl +10 -0
  277. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_evaluation/prompt_evaluation_dataset/profile_update_main_v1.jsonl +10 -0
  278. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_manager.py +280 -0
  279. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_schema.py +11 -0
  280. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/_eval_health.py +131 -0
  281. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_constants.py +60 -0
  282. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_service.py +228 -0
  283. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluation_utils.py +87 -0
  284. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/agent_success_evaluator.py +372 -0
  285. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/delayed_group_evaluator.py +156 -0
  286. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/group_evaluation_runner.py +340 -0
  287. package/plugin/vendor/reflexio/reflexio/server/services/agent_success_evaluation/regen_jobs.py +471 -0
  288. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +1626 -0
  289. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/__init__.py +0 -0
  290. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_cron.py +196 -0
  291. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/_encryption.py +101 -0
  292. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/client.py +167 -0
  293. package/plugin/vendor/reflexio/reflexio/server/services/braintrust/service.py +281 -0
  294. package/plugin/vendor/reflexio/reflexio/server/services/configurator/base_configurator.py +179 -0
  295. package/plugin/vendor/reflexio/reflexio/server/services/configurator/config_storage.py +62 -0
  296. package/plugin/vendor/reflexio/reflexio/server/services/configurator/configurator.py +87 -0
  297. package/plugin/vendor/reflexio/reflexio/server/services/configurator/local_file_config_storage.py +187 -0
  298. package/plugin/vendor/reflexio/reflexio/server/services/configurator/test_config_storage.py +162 -0
  299. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +112 -0
  300. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/__init__.py +0 -0
  301. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/distribution.py +33 -0
  302. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/eval_sampler.py +126 -0
  303. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/group_aggregation.py +192 -0
  304. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/hero_state.py +75 -0
  305. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/rule_attribution.py +97 -0
  306. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/service.py +515 -0
  307. package/plugin/vendor/reflexio/reflexio/server/services/evaluation_overview/shadow_aggregation.py +90 -0
  308. package/plugin/vendor/reflexio/reflexio/server/services/extraction/__init__.py +0 -0
  309. package/plugin/vendor/reflexio/reflexio/server/services/extraction/agent_run_records.py +91 -0
  310. package/plugin/vendor/reflexio/reflexio/server/services/extraction/invariants.py +303 -0
  311. package/plugin/vendor/reflexio/reflexio/server/services/extraction/outcome.py +25 -0
  312. package/plugin/vendor/reflexio/reflexio/server/services/extraction/pending_tool_call_dispatch.py +351 -0
  313. package/plugin/vendor/reflexio/reflexio/server/services/extraction/plan.py +138 -0
  314. package/plugin/vendor/reflexio/reflexio/server/services/extraction/prior_answer_search.py +217 -0
  315. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resumable_agent.py +468 -0
  316. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +171 -0
  317. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +777 -0
  318. package/plugin/vendor/reflexio/reflexio/server/services/extraction/tools.py +1125 -0
  319. package/plugin/vendor/reflexio/reflexio/server/services/extractor_config_utils.py +91 -0
  320. package/plugin/vendor/reflexio/reflexio/server/services/extractor_interaction_utils.py +251 -0
  321. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +689 -0
  322. package/plugin/vendor/reflexio/reflexio/server/services/operation_state_utils.py +835 -0
  323. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +89 -0
  324. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_aggregator.py +1388 -0
  325. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_consolidator.py +960 -0
  326. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_extractor.py +436 -0
  327. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_generation_service.py +808 -0
  328. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_constants.py +28 -0
  329. package/plugin/vendor/reflexio/reflexio/server/services/playbook/playbook_service_utils.py +362 -0
  330. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/__init__.py +24 -0
  331. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/assistant_webhook.py +246 -0
  332. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/gepa_adapter.py +291 -0
  333. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/judge.py +97 -0
  334. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/models.py +96 -0
  335. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/optimizer.py +645 -0
  336. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/rollout.py +35 -0
  337. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scenario_resolver.py +93 -0
  338. package/plugin/vendor/reflexio/reflexio/server/services/playbook_optimizer/scheduler.py +174 -0
  339. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/__init__.py +26 -0
  340. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_document_expander.py +179 -0
  341. package/plugin/vendor/reflexio/reflexio/server/services/pre_retrieval/_query_reformulator.py +297 -0
  342. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_deduplicator.py +741 -0
  343. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_extractor.py +462 -0
  344. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service.py +734 -0
  345. package/plugin/vendor/reflexio/reflexio/server/services/profile/profile_generation_service_utils.py +290 -0
  346. package/plugin/vendor/reflexio/reflexio/server/services/reflection/__init__.py +17 -0
  347. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_extractor.py +247 -0
  348. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service.py +800 -0
  349. package/plugin/vendor/reflexio/reflexio/server/services/reflection/reflection_service_utils.py +146 -0
  350. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/__init__.py +0 -0
  351. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +70 -0
  352. package/plugin/vendor/reflexio/reflexio/server/services/search/__init__.py +0 -0
  353. package/plugin/vendor/reflexio/reflexio/server/services/service_utils.py +671 -0
  354. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/__init__.py +1 -0
  355. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/judge.py +184 -0
  356. package/plugin/vendor/reflexio/reflexio/server/services/shadow_comparison/outcome.py +81 -0
  357. package/plugin/vendor/reflexio/reflexio/server/services/storage/constants.py +2 -0
  358. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +11 -0
  359. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention.py +154 -0
  360. package/plugin/vendor/reflexio/reflexio/server/services/storage/retention_mixin.py +155 -0
  361. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +59 -0
  362. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +1253 -0
  363. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +1945 -0
  364. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +600 -0
  365. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_operations.py +346 -0
  366. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1378 -0
  367. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +747 -0
  368. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +263 -0
  369. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_shadow_verdicts.py +193 -0
  370. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +166 -0
  371. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_stall_state.py +217 -0
  372. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +153 -0
  373. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +372 -0
  374. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_base.py +71 -0
  375. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +235 -0
  376. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_operations.py +170 -0
  377. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +677 -0
  378. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +250 -0
  379. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +154 -0
  380. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_shadow_verdicts.py +130 -0
  381. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +93 -0
  382. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_stall_state.py +76 -0
  383. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +568 -0
  384. package/plugin/vendor/reflexio/reflexio/server/site_var/README.md +77 -0
  385. package/plugin/vendor/reflexio/reflexio/server/site_var/feature_flags.py +116 -0
  386. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_manager.py +263 -0
  387. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/feature_flags.json +13 -0
  388. package/plugin/vendor/reflexio/reflexio/server/site_var/site_var_sources/llm_model_setting.json +7 -0
  389. package/plugin/vendor/reflexio/reflexio/server/tracing.py +158 -0
  390. package/plugin/vendor/reflexio/reflexio/server/usage_metrics.py +113 -0
  391. package/plugin/vendor/reflexio/reflexio/server/uvicorn_logging.py +76 -0
  392. package/plugin/vendor/reflexio/reflexio/test_support/__init__.py +1 -0
  393. package/plugin/vendor/reflexio/reflexio/test_support/llm_fixtures.py +62 -0
  394. package/plugin/vendor/reflexio/reflexio/test_support/llm_mock.py +242 -0
  395. package/plugin/vendor/reflexio/reflexio/test_support/llm_model_registry.py +129 -0
  396. package/plugin/vendor/reflexio/reflexio/test_support/skip_decorators.py +43 -0
@@ -0,0 +1,1388 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import time
7
+ from collections.abc import Sequence
8
+ from typing import TYPE_CHECKING
9
+
10
+ if TYPE_CHECKING:
11
+ import numpy as np
12
+
13
+ from reflexio.models.api_schema.service_schemas import (
14
+ AgentPlaybook,
15
+ AgentPlaybookSnapshot,
16
+ AgentPlaybookSourceWindow,
17
+ AgentPlaybookUpdateEntry,
18
+ PlaybookAggregationChangeLog,
19
+ PlaybookStatus,
20
+ UserPlaybook,
21
+ agent_playbook_to_snapshot,
22
+ )
23
+ from reflexio.models.config_schema import (
24
+ SINGLETON_USER_PLAYBOOK_NAME,
25
+ PlaybookAggregatorConfig,
26
+ )
27
+ from reflexio.server.api_endpoints.request_context import RequestContext
28
+ from reflexio.server.llm.litellm_client import LiteLLMClient
29
+ from reflexio.server.services.operation_state_utils import OperationStateManager
30
+ from reflexio.server.services.playbook.playbook_service_constants import (
31
+ PlaybookServiceConstants,
32
+ )
33
+ from reflexio.server.services.playbook.playbook_service_utils import (
34
+ PlaybookAggregationOutput,
35
+ PlaybookAggregatorRequest,
36
+ ensure_playbook_content,
37
+ )
38
+ from reflexio.server.services.service_utils import log_model_response
39
+ from reflexio.server.usage_metrics import record_usage_event
40
+
41
+ logger = logging.getLogger(__name__)
42
+
43
+ # Threshold for switching between clustering algorithms
44
+ # Below this, use Agglomerative (works better with small datasets)
45
+ # Above this, use HDBSCAN (scales better, handles noise)
46
+ CLUSTERING_ALGORITHM_THRESHOLD = 50
47
+
48
+
49
+ class PlaybookAggregator:
50
+ def __init__(
51
+ self,
52
+ llm_client: LiteLLMClient,
53
+ request_context: RequestContext,
54
+ agent_version: str,
55
+ ) -> None:
56
+ self.client = llm_client
57
+ self.storage = request_context.storage
58
+ self.configurator = request_context.configurator
59
+ self.request_context = request_context
60
+ self.agent_version = agent_version
61
+
62
+ # ===============================
63
+ # private methods - operation state
64
+ # ===============================
65
+
66
+ def _create_state_manager(self) -> OperationStateManager:
67
+ """
68
+ Create an OperationStateManager for the playbook aggregator.
69
+
70
+ Returns:
71
+ OperationStateManager configured for playbook_aggregator
72
+ """
73
+ return OperationStateManager(
74
+ self.storage, # type: ignore[reportArgumentType]
75
+ self.request_context.org_id,
76
+ "playbook_aggregator",
77
+ )
78
+
79
+ def _get_new_user_playbooks_count(
80
+ self, playbook_name: str, rerun: bool = False
81
+ ) -> int:
82
+ """
83
+ Count how many new user playbooks exist since last aggregation.
84
+ Uses efficient SQL COUNT query instead of fetching all user playbooks.
85
+
86
+ Args:
87
+ playbook_name: Name of the playbook type
88
+ rerun: If True, count all user playbooks (use last_processed_id=0)
89
+
90
+ Returns:
91
+ int: Count of new user playbooks
92
+ """
93
+ # For rerun, use 0 to process all user playbooks
94
+ if rerun:
95
+ last_processed_id = 0
96
+ else:
97
+ mgr = self._create_state_manager()
98
+ bookmark = mgr.get_aggregator_bookmark(
99
+ name=playbook_name, version=self.agent_version
100
+ )
101
+ last_processed_id = bookmark if bookmark is not None else 0
102
+
103
+ # Count user playbooks with ID greater than last processed using efficient count query
104
+ # Only count current user playbooks (status=None), not archived or pending ones.
105
+ # Singleton aggregation operates on the user's whole playbook set — no name filter.
106
+ new_count = self.storage.count_user_playbooks( # type: ignore[reportOptionalMemberAccess]
107
+ min_user_playbook_id=last_processed_id,
108
+ agent_version=self.agent_version,
109
+ status_filter=[None],
110
+ )
111
+
112
+ logger.info(
113
+ "Found %d new user playbooks for '%s' (agent_version=%s, last processed ID: %d)",
114
+ new_count,
115
+ playbook_name,
116
+ self.agent_version,
117
+ last_processed_id,
118
+ )
119
+
120
+ return new_count
121
+
122
+ def _should_run_aggregation(
123
+ self,
124
+ playbook_name: str,
125
+ playbook_aggregator_config: PlaybookAggregatorConfig,
126
+ rerun: bool = False,
127
+ ) -> bool:
128
+ """
129
+ Check if aggregation should run based on new user playbooks count.
130
+
131
+ Args:
132
+ playbook_name: Name of the playbook type
133
+ playbook_aggregator_config: Configuration for playbook aggregator
134
+ rerun: If True, count all user playbooks to determine if aggregation is needed
135
+
136
+ Returns:
137
+ bool: True if aggregation should run, False otherwise
138
+ """
139
+ # Get reaggregation_trigger_count, default to 2 if not set or 0
140
+ trigger_count = playbook_aggregator_config.reaggregation_trigger_count
141
+ if trigger_count <= 0:
142
+ trigger_count = 2
143
+
144
+ # Check new user playbooks count (uses all playbooks if rerun=True)
145
+ new_count = self._get_new_user_playbooks_count(playbook_name, rerun=rerun)
146
+
147
+ return new_count >= trigger_count
148
+
149
+ def _update_operation_state(
150
+ self, playbook_name: str, user_playbooks: list[UserPlaybook]
151
+ ) -> None:
152
+ """
153
+ Update operation state with the highest user_playbook_id processed.
154
+
155
+ Args:
156
+ playbook_name: Name of the playbook type
157
+ user_playbooks: List of user playbooks that were processed
158
+ """
159
+ if not user_playbooks:
160
+ return
161
+
162
+ # Find max user_playbook_id
163
+ max_id = max(playbook.user_playbook_id for playbook in user_playbooks)
164
+
165
+ mgr = self._create_state_manager()
166
+ mgr.update_aggregator_bookmark(
167
+ name=playbook_name,
168
+ version=self.agent_version,
169
+ last_processed_id=max_id,
170
+ )
171
+
172
+ def _format_cluster_input(self, cluster_playbooks: list[UserPlaybook]) -> str:
173
+ """
174
+ Format a cluster of playbooks for the aggregation prompt using per-item format.
175
+
176
+ Each playbook is shown as a self-contained unit with content as the
177
+ primary content, followed by optional structured fields as supplementary metadata.
178
+
179
+ Args:
180
+ cluster_playbooks: List of raw playbooks in this cluster
181
+
182
+ Returns:
183
+ str: Formatted input for the aggregation prompt
184
+ """
185
+ blocks = []
186
+ for idx, fb in enumerate(cluster_playbooks, 1):
187
+ lines = [f"[{idx}]"]
188
+ if fb.content:
189
+ lines.append(f'Content: "{fb.content}"')
190
+ if fb.trigger:
191
+ lines.append(f'Trigger: "{fb.trigger}"')
192
+ if fb.rationale:
193
+ lines.append(f'Rationale: "{fb.rationale}"')
194
+ blocks.append("\n".join(lines))
195
+ return "\n\n".join(blocks) if blocks else "(No playbook items)"
196
+
197
+ @staticmethod
198
+ def _get_direction_key(fb: UserPlaybook) -> str:
199
+ """
200
+ Extract a similarity key from a user playbook for grouping.
201
+
202
+ Returns the raw content used for token-overlap comparison. Grouping is
203
+ purely content-similarity based: under Option B a skill may legitimately
204
+ hold mixed-orientation rules (do-rules and avoid-rules for different
205
+ sub-aspects of one task), so whole-content polarity is NOT derived or
206
+ gated here. Preserving distinct do/avoid rules when similar items are
207
+ merged is the aggregation prompt's responsibility.
208
+
209
+ Args:
210
+ fb: A user playbook item
211
+
212
+ Returns:
213
+ str: Content used as the similarity key for grouping
214
+ """
215
+ return fb.content or ""
216
+
217
+ @staticmethod
218
+ def _token_overlap(str1: str, str2: str, threshold: float = 0.6) -> bool:
219
+ """
220
+ Check if two strings have significant token overlap using asymmetric containment.
221
+
222
+ Computes the ratio of shared tokens to the smaller set, so a short string
223
+ contained in a longer one still counts as a match.
224
+
225
+ Args:
226
+ str1: First string
227
+ str2: Second string
228
+ threshold: Minimum overlap ratio
229
+
230
+ Returns:
231
+ bool: True if overlap ratio >= threshold
232
+ """
233
+ tokens1 = set(str1.lower().split())
234
+ tokens2 = set(str2.lower().split())
235
+ if not tokens1 or not tokens2:
236
+ return False
237
+ intersection = len(tokens1 & tokens2)
238
+ overlap_ratio = max(intersection / len(tokens1), intersection / len(tokens2))
239
+ return overlap_ratio >= threshold
240
+
241
+ @staticmethod
242
+ def _group_playbooks_by_direction(
243
+ cluster_playbooks: list[UserPlaybook],
244
+ threshold: float = 0.6,
245
+ ) -> list[list[UserPlaybook]]:
246
+ """
247
+ Group playbooks by similarity of their content.
248
+
249
+ Uses greedy single-linkage: each playbook is assigned to the first
250
+ existing group that has any member with sufficient token overlap.
251
+ Groups are returned sorted by size descending (largest first).
252
+
253
+ Grouping is purely content-similarity based and does NOT gate on a
254
+ derived whole-content polarity. Under Option B a skill may hold
255
+ mixed-orientation rules (do-rules and avoid-rules for different
256
+ sub-aspects), and whole-content polarity is undefined for such a skill.
257
+ Keeping a do-rule and an avoid-rule as distinct rules when similar items
258
+ are merged into one skill is the aggregation prompt's responsibility,
259
+ not a mechanical split here.
260
+
261
+ Args:
262
+ cluster_playbooks: List of raw playbooks to group
263
+ threshold: Token overlap threshold for grouping
264
+
265
+ Returns:
266
+ list[list[UserPlaybook]]: Groups sorted by size descending
267
+ """
268
+ groups: list[list[UserPlaybook]] = []
269
+
270
+ for fb in cluster_playbooks:
271
+ key = PlaybookAggregator._get_direction_key(fb)
272
+ matched = False
273
+ for group in groups:
274
+ if any(
275
+ PlaybookAggregator._token_overlap(
276
+ key,
277
+ PlaybookAggregator._get_direction_key(group_fb),
278
+ threshold,
279
+ )
280
+ for group_fb in group
281
+ ):
282
+ group.append(fb)
283
+ matched = True
284
+ break
285
+ if not matched:
286
+ groups.append([fb])
287
+
288
+ # Sort by group size descending (largest first)
289
+ groups.sort(key=len, reverse=True)
290
+ return groups
291
+
292
+ def _format_structured_cluster_input(
293
+ self,
294
+ cluster_playbooks: list[UserPlaybook],
295
+ direction_overlap_threshold: float = 0.6,
296
+ ) -> str:
297
+ """
298
+ Format a cluster of playbooks for structured aggregation prompt.
299
+
300
+ When the cluster forms a single similarity group, uses the flat-list
301
+ format. When distinct similarity groups are detected (multiple groups),
302
+ uses a grouped format so the LLM can see which items are similar and
303
+ preserve distinct rules (e.g. a do-rule and an avoid-rule) as separate
304
+ rules in the merged skill rather than collapsing them.
305
+
306
+ Args:
307
+ cluster_playbooks: List of raw playbooks in this cluster
308
+ direction_overlap_threshold: Token overlap threshold for grouping by direction
309
+
310
+ Returns:
311
+ str: Formatted input for the aggregation prompt
312
+ """
313
+ groups = self._group_playbooks_by_direction(
314
+ cluster_playbooks, threshold=direction_overlap_threshold
315
+ )
316
+
317
+ if len(groups) <= 1:
318
+ return self._format_flat(cluster_playbooks)
319
+ return self._format_grouped(groups)
320
+
321
+ def _format_flat(self, cluster_playbooks: list[UserPlaybook]) -> str:
322
+ """
323
+ Format playbooks as flat bullet lists (original format, used when no conflict).
324
+
325
+ Args:
326
+ cluster_playbooks: List of raw playbooks in this cluster
327
+
328
+ Returns:
329
+ str: Formatted input with separate field lists
330
+ """
331
+ triggers = []
332
+ rationales = []
333
+
334
+ for fb in cluster_playbooks:
335
+ if fb.trigger:
336
+ triggers.append(fb.trigger)
337
+ if fb.rationale:
338
+ rationales.append(fb.rationale)
339
+
340
+ lines: list[str] = []
341
+
342
+ if triggers:
343
+ lines.append("TRIGGER conditions (to be consolidated):")
344
+ lines.extend(f"- {trigger}" for trigger in triggers)
345
+ else:
346
+ lines.append("TRIGGER conditions: (none specified)")
347
+
348
+ if rationales:
349
+ lines.append("RATIONALE summaries:")
350
+ lines.extend(f"- {r}" for r in rationales)
351
+
352
+ self._append_freeform_observations(lines, cluster_playbooks)
353
+
354
+ return "\n".join(lines)
355
+
356
+ def _format_grouped(
357
+ self,
358
+ groups: list[list[UserPlaybook]],
359
+ ) -> str:
360
+ """
361
+ Format playbooks in grouped layout (used when conflicting directions are detected).
362
+
363
+ Args:
364
+ groups: AgentPlaybook groups sorted by size descending
365
+
366
+ Returns:
367
+ str: Formatted input with group headers and per-playbook fields
368
+ """
369
+ lines: list[str] = [
370
+ "The following playbook items are grouped by similarity. "
371
+ "Groups are ordered by size (largest first).",
372
+ "",
373
+ ]
374
+
375
+ for idx, group in enumerate(groups, start=1):
376
+ count_label = "playbook" if len(group) == 1 else "playbooks"
377
+ lines.append(f"Group {idx} ({len(group)} {count_label}):")
378
+ for fb in group:
379
+ parts: list[str] = []
380
+ if fb.trigger:
381
+ parts.append(f'Trigger: "{fb.trigger}"')
382
+ if fb.rationale:
383
+ parts.append(f'Rationale: "{fb.rationale}"')
384
+ if not parts and fb.content:
385
+ parts.append(f'AgentPlaybook: "{fb.content}"')
386
+ if parts:
387
+ lines.append(f" - {parts[0]}")
388
+ lines.extend(f" {p}" for p in parts[1:])
389
+ lines.append("")
390
+
391
+ return "\n".join(lines)
392
+
393
+ @staticmethod
394
+ def _append_freeform_observations(
395
+ lines: list[str], cluster_playbooks: list[UserPlaybook]
396
+ ) -> None:
397
+ """Append freeform observations from cluster playbooks to output lines."""
398
+ freeform_observations = [
399
+ fb.content for fb in cluster_playbooks if not fb.trigger and fb.content
400
+ ]
401
+ if freeform_observations:
402
+ lines.append("Freeform observations (from freeform cluster members):")
403
+ lines.extend(f"- {obs}" for obs in freeform_observations)
404
+
405
+ # ===============================
406
+ # private methods - cluster change detection
407
+ # ===============================
408
+
409
+ @staticmethod
410
+ def _compute_cluster_fingerprint(cluster_playbooks: list[UserPlaybook]) -> str:
411
+ """
412
+ Compute a fingerprint for a cluster based on its user_playbook_ids.
413
+ The fingerprint is deterministic and order-independent.
414
+
415
+ Args:
416
+ cluster_playbooks: List of raw playbooks in this cluster
417
+
418
+ Returns:
419
+ str: SHA-256 hash (truncated to 16 hex chars) of sorted user_playbook_ids
420
+ """
421
+ sorted_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
422
+ id_str = ",".join(str(id) for id in sorted_ids)
423
+ return hashlib.sha256(id_str.encode()).hexdigest()[:16]
424
+
425
+ def _determine_cluster_changes(
426
+ self,
427
+ clusters: dict[int, list[UserPlaybook]],
428
+ prev_fingerprints: dict,
429
+ ) -> tuple[dict[int, list[UserPlaybook]], list[int]]:
430
+ """
431
+ Compare current cluster fingerprints against stored fingerprints to determine changes.
432
+
433
+ Args:
434
+ clusters: Current clusters (cluster_id -> list of UserPlaybook)
435
+ prev_fingerprints: Previous fingerprint state
436
+ (fingerprint_hash -> {"agent_playbook_id": int, "user_playbook_ids": list})
437
+
438
+ Returns:
439
+ tuple of:
440
+ - changed_clusters: Only clusters needing new LLM calls
441
+ - playbook_ids_to_archive: Old playbook_ids from changed/disappeared clusters
442
+ """
443
+ # Compute fingerprints for current clusters
444
+ current_fingerprints = {}
445
+ for cluster_id, cluster_playbooks in clusters.items():
446
+ fp = self._compute_cluster_fingerprint(cluster_playbooks)
447
+ current_fingerprints[cluster_id] = fp
448
+
449
+ current_fp_set = set(current_fingerprints.values())
450
+ prev_fp_set = set(prev_fingerprints.keys())
451
+
452
+ # Changed clusters: fingerprints that are new (not in previous state)
453
+ changed_clusters = {}
454
+ for cluster_id, fp in current_fingerprints.items():
455
+ if fp not in prev_fp_set:
456
+ changed_clusters[cluster_id] = clusters[cluster_id]
457
+
458
+ # Playbook IDs to archive: from fingerprints that disappeared or changed
459
+ playbook_ids_to_archive = []
460
+ for fp, fp_data in prev_fingerprints.items():
461
+ if fp not in current_fp_set:
462
+ playbook_id = fp_data.get("agent_playbook_id")
463
+ if playbook_id is not None:
464
+ playbook_ids_to_archive.append(playbook_id)
465
+
466
+ return changed_clusters, playbook_ids_to_archive
467
+
468
+ def _build_change_log(
469
+ self,
470
+ playbook_name: str,
471
+ full_archive: bool,
472
+ before_playbooks_by_id: dict[int, AgentPlaybook],
473
+ saved_playbooks: list[AgentPlaybook],
474
+ archived_playbook_ids: list[int],
475
+ prev_fingerprints: dict,
476
+ ) -> PlaybookAggregationChangeLog:
477
+ """Build a PlaybookAggregationChangeLog from the aggregation run results.
478
+
479
+ Args:
480
+ playbook_name: The playbook name being aggregated
481
+ full_archive: Whether this was a full archive (rerun/first run)
482
+ before_playbooks_by_id: Snapshot of playbooks before archiving, keyed by playbook_id
483
+ saved_playbooks: Newly saved playbooks from this run
484
+ archived_playbook_ids: AgentPlaybook IDs that were selectively archived (incremental mode)
485
+ prev_fingerprints: Previous cluster fingerprints (empty for full archive)
486
+
487
+ Returns:
488
+ PlaybookAggregationChangeLog with added/removed/updated lists populated
489
+ """
490
+ added: list[AgentPlaybookSnapshot] = []
491
+ removed: list[AgentPlaybookSnapshot] = []
492
+ updated: list[AgentPlaybookUpdateEntry] = []
493
+
494
+ if full_archive:
495
+ # No 1:1 mapping — all old playbooks are removed, all new are added
496
+ removed = [
497
+ agent_playbook_to_snapshot(fb) for fb in before_playbooks_by_id.values()
498
+ ]
499
+ added = [agent_playbook_to_snapshot(fb) for fb in saved_playbooks if fb]
500
+ else:
501
+ # Incremental mode: map old playbook_ids to new playbooks via fingerprints
502
+ # Build a set of old playbook_ids that were archived
503
+ archived_id_set = set(archived_playbook_ids)
504
+
505
+ # Build mapping: old_playbook_id -> new_playbook_id via fingerprint changes
506
+ # prev_fingerprints maps fp_hash -> {playbook_id, user_playbook_ids}
507
+ # new_fingerprints maps fp_hash -> {playbook_id, user_playbook_ids}
508
+ # If an old fingerprint disappeared and a new one appeared, and
509
+ # the old fp had a playbook_id in archived_id_set, we can try to pair them.
510
+ # However, without a direct cluster-level old->new mapping, we use a simpler approach:
511
+ # archived playbooks that have a corresponding new playbook (by position in saved list) are updates.
512
+
513
+ # Collect old playbook_ids from disappeared fingerprints
514
+ old_fp_playbook_ids = {}
515
+ for fp, fp_data in prev_fingerprints.items():
516
+ fid = fp_data.get("agent_playbook_id")
517
+ if fid is not None and fid in archived_id_set:
518
+ old_fp_playbook_ids[fid] = fp
519
+
520
+ # For each saved playbook, try to match with an archived old playbook
521
+ matched_old_ids: set[int] = set()
522
+ for saved_fb in saved_playbooks:
523
+ if not saved_fb:
524
+ continue
525
+ # Try to find an old playbook from the archived set to pair with
526
+ paired_old_id = None
527
+ for old_id in list(old_fp_playbook_ids.keys()):
528
+ if old_id not in matched_old_ids:
529
+ paired_old_id = old_id
530
+ matched_old_ids.add(old_id)
531
+ break
532
+
533
+ if (
534
+ paired_old_id is not None
535
+ and paired_old_id in before_playbooks_by_id
536
+ ):
537
+ updated.append(
538
+ AgentPlaybookUpdateEntry(
539
+ before=agent_playbook_to_snapshot(
540
+ before_playbooks_by_id[paired_old_id]
541
+ ),
542
+ after=agent_playbook_to_snapshot(saved_fb),
543
+ )
544
+ )
545
+ else:
546
+ added.append(agent_playbook_to_snapshot(saved_fb))
547
+
548
+ # Remaining archived playbooks that weren't paired are removals
549
+ for old_id in archived_id_set:
550
+ if old_id not in matched_old_ids and old_id in before_playbooks_by_id:
551
+ removed.append(
552
+ agent_playbook_to_snapshot(before_playbooks_by_id[old_id])
553
+ )
554
+
555
+ return PlaybookAggregationChangeLog(
556
+ playbook_name=playbook_name,
557
+ agent_version=self.agent_version,
558
+ run_mode="full_archive" if full_archive else "incremental",
559
+ added_agent_playbooks=added,
560
+ removed_agent_playbooks=removed,
561
+ updated_agent_playbooks=updated,
562
+ )
563
+
564
+ # ===============================
565
+ # public methods
566
+ # ===============================
567
+
568
+ def run(self, playbook_aggregator_request: PlaybookAggregatorRequest) -> dict: # noqa: C901
569
+ """Run playbook aggregation.
570
+
571
+ Returns:
572
+ dict: Aggregation stats with keys: clusters_found, user_playbooks_processed, playbooks_generated, skipped (optional)
573
+ """
574
+ aggregation_start = time.perf_counter()
575
+ _empty_stats = {
576
+ "clusters_found": 0,
577
+ "user_playbooks_processed": 0,
578
+ "playbooks_generated": 0,
579
+ }
580
+
581
+ # Singleton aggregation: one playbook kind per org. The name is a fixed
582
+ # constant used only for bookmark/archive scoping and telemetry — it is
583
+ # never a selection filter on the read queries below.
584
+ playbook_name = SINGLETON_USER_PLAYBOOK_NAME
585
+
586
+ # get playbook aggregator config
587
+ playbook_aggregator_config = self._get_playbook_aggregator_config()
588
+ if (
589
+ not playbook_aggregator_config
590
+ or playbook_aggregator_config.min_cluster_size < 2
591
+ ):
592
+ skip_reason = "no aggregator config or min_cluster_size < 2"
593
+ record_usage_event(
594
+ org_id=self.request_context.org_id,
595
+ event_name="aggregation_gate_evaluated",
596
+ event_category="aggregation",
597
+ pipeline="playbook",
598
+ playbook_name=playbook_name,
599
+ agent_version=self.agent_version,
600
+ outcome="should_skip",
601
+ metadata={"skip_reason": skip_reason},
602
+ )
603
+ logger.info(
604
+ "Skipping user playbook aggregation for '%s' (agent_version=%s): no aggregator config or min_cluster_size < 2, config: %s",
605
+ playbook_name,
606
+ self.agent_version,
607
+ playbook_aggregator_config,
608
+ )
609
+ return {
610
+ **_empty_stats,
611
+ "skipped": skip_reason,
612
+ }
613
+
614
+ # Check if we should run aggregation based on new playbooks count
615
+ # For rerun, use all user playbooks (last_processed_id=0) to determine if aggregation is needed
616
+ if not self._should_run_aggregation(
617
+ playbook_name,
618
+ playbook_aggregator_config,
619
+ rerun=playbook_aggregator_request.rerun,
620
+ ):
621
+ new_count = self._get_new_user_playbooks_count(
622
+ playbook_name,
623
+ rerun=playbook_aggregator_request.rerun,
624
+ )
625
+ trigger_count = (
626
+ playbook_aggregator_config.reaggregation_trigger_count
627
+ if playbook_aggregator_config.reaggregation_trigger_count > 0
628
+ else 2
629
+ )
630
+ logger.info(
631
+ "Skipping user playbook aggregation for '%s' (agent_version=%s) - only %d new user playbooks (need %d)",
632
+ playbook_name,
633
+ self.agent_version,
634
+ new_count,
635
+ trigger_count,
636
+ )
637
+ record_usage_event(
638
+ org_id=self.request_context.org_id,
639
+ event_name="aggregation_gate_evaluated",
640
+ event_category="aggregation",
641
+ pipeline="playbook",
642
+ playbook_name=playbook_name,
643
+ agent_version=self.agent_version,
644
+ outcome="should_skip",
645
+ count_value=new_count,
646
+ metadata={
647
+ "new_user_playbooks": new_count,
648
+ "trigger_count": trigger_count,
649
+ },
650
+ )
651
+ return {
652
+ **_empty_stats,
653
+ "skipped": f"not enough new playbooks ({new_count} < {trigger_count})",
654
+ }
655
+
656
+ record_usage_event(
657
+ org_id=self.request_context.org_id,
658
+ event_name="aggregation_gate_evaluated",
659
+ event_category="aggregation",
660
+ pipeline="playbook",
661
+ playbook_name=playbook_name,
662
+ agent_version=self.agent_version,
663
+ outcome="should_run",
664
+ )
665
+ logger.info(
666
+ "Running user playbook aggregation for '%s' (agent_version=%s)",
667
+ playbook_name,
668
+ self.agent_version,
669
+ )
670
+
671
+ # Get existing APPROVED and PENDING playbooks before archiving (to pass to LLM for deduplication).
672
+ # Singleton aggregation pulls the user's whole set — no name filter.
673
+ existing_playbooks = self.storage.get_agent_playbooks( # type: ignore[reportOptionalMemberAccess]
674
+ status_filter=[None], # Current playbooks only
675
+ playbook_status_filter=[PlaybookStatus.APPROVED, PlaybookStatus.PENDING],
676
+ )
677
+ logger.info(
678
+ "Found %s existing playbooks (approved + pending) to preserve",
679
+ len(existing_playbooks),
680
+ )
681
+
682
+ # get all user playbooks and generate clusters
683
+ user_playbooks = self.storage.get_user_playbooks( # type: ignore[reportOptionalMemberAccess]
684
+ agent_version=self.agent_version,
685
+ include_embedding=True,
686
+ )
687
+ full_archive_playbook_names = sorted(
688
+ {
689
+ playbook.playbook_name
690
+ for playbook in [*existing_playbooks, *user_playbooks]
691
+ if playbook.playbook_name
692
+ }
693
+ | {playbook_name}
694
+ )
695
+ clusters = self.get_clusters(user_playbooks, playbook_aggregator_config)
696
+
697
+ # Capture all current playbooks before archiving (for change log)
698
+ before_playbooks_by_id: dict[int, AgentPlaybook] = {
699
+ fb.agent_playbook_id: fb for fb in existing_playbooks
700
+ }
701
+
702
+ # Determine which clusters changed (skip for rerun)
703
+ mgr = self._create_state_manager()
704
+ archived_playbook_ids = []
705
+ full_archive = False
706
+ prev_fingerprints: dict = {} # Populated for incremental mode
707
+
708
+ # Deferred-archive flag: full archive is performed AFTER LLM generation,
709
+ # and only when at least one new playbook was produced. Avoids silently
710
+ # dropping existing PENDING/APPROVED playbooks when the LLM returns
711
+ # null (cluster identified as duplicate of existing).
712
+ pending_full_archive = False
713
+
714
+ if playbook_aggregator_request.rerun:
715
+ logger.info("Rerun requested: bypassing cluster change detection")
716
+ changed_clusters = clusters
717
+ full_archive = True
718
+ pending_full_archive = True
719
+ else:
720
+ # Load previous fingerprints and detect changes
721
+ prev_fingerprints = mgr.get_cluster_fingerprints(
722
+ name=playbook_name, version=self.agent_version
723
+ )
724
+
725
+ if not prev_fingerprints:
726
+ logger.info(
727
+ "No previous cluster fingerprints found, treating all clusters as changed"
728
+ )
729
+ changed_clusters = clusters
730
+ full_archive = True
731
+ pending_full_archive = True
732
+ else:
733
+ (
734
+ changed_clusters,
735
+ archived_playbook_ids,
736
+ ) = self._determine_cluster_changes(clusters, prev_fingerprints)
737
+
738
+ if not changed_clusters and not archived_playbook_ids:
739
+ logger.info(
740
+ "No cluster changes detected for '%s', skipping LLM calls",
741
+ playbook_name,
742
+ )
743
+ # Still update bookmark
744
+ self._update_operation_state(playbook_name, user_playbooks)
745
+ record_usage_event(
746
+ org_id=self.request_context.org_id,
747
+ event_name="aggregation_succeeded",
748
+ event_category="aggregation",
749
+ pipeline="playbook",
750
+ playbook_name=playbook_name,
751
+ agent_version=self.agent_version,
752
+ outcome="success",
753
+ count_value=0,
754
+ duration_ms=int(
755
+ (time.perf_counter() - aggregation_start) * 1000
756
+ ),
757
+ metadata={"skipped": "no cluster changes detected"},
758
+ )
759
+ return {**_empty_stats, "skipped": "no cluster changes detected"}
760
+
761
+ logger.info(
762
+ "Detected %d changed clusters, %d playbooks to archive",
763
+ len(changed_clusters),
764
+ len(archived_playbook_ids),
765
+ )
766
+
767
+ # Selectively archive only playbooks from changed/disappeared clusters
768
+ if archived_playbook_ids:
769
+ self.storage.archive_agent_playbooks_by_ids(archived_playbook_ids) # type: ignore[reportOptionalMemberAccess]
770
+
771
+ try:
772
+ # Emit the started event inside the protected block so any failure
773
+ # from here on is paired with an aggregation_failed event.
774
+ record_usage_event(
775
+ org_id=self.request_context.org_id,
776
+ event_name="aggregation_started",
777
+ event_category="aggregation",
778
+ pipeline="playbook",
779
+ playbook_name=playbook_name,
780
+ agent_version=self.agent_version,
781
+ outcome="started",
782
+ )
783
+ # Generate new playbooks only for changed clusters while preserving
784
+ # the exact source cluster for each non-duplicate playbook.
785
+ generated_pairs = self._generate_playbooks_with_source_clusters(
786
+ changed_clusters,
787
+ existing_playbooks,
788
+ direction_overlap_threshold=playbook_aggregator_config.direction_overlap_threshold,
789
+ )
790
+ new_playbooks = [playbook for playbook, _ in generated_pairs]
791
+
792
+ # Lazy archive: only full-archive when the LLM produced replacements.
793
+ # Skipping the archive when new_playbooks is empty preserves existing
794
+ # PENDING/APPROVED playbooks that the LLM identified as duplicates.
795
+ if pending_full_archive:
796
+ if new_playbooks:
797
+ for name in full_archive_playbook_names:
798
+ self.storage.archive_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
799
+ name, agent_version=self.agent_version
800
+ )
801
+ else:
802
+ logger.info(
803
+ "Skipping full archive of %s (agent_version=%s): LLM produced 0 new playbooks; existing PENDING/APPROVED playbooks preserved",
804
+ full_archive_playbook_names,
805
+ self.agent_version,
806
+ )
807
+ full_archive = False
808
+
809
+ # Save playbooks (returns playbooks with playbook_id populated)
810
+ saved_playbooks = self.storage.save_agent_playbooks(new_playbooks) # type: ignore[reportOptionalMemberAccess]
811
+
812
+ # Build new fingerprint state
813
+ new_fingerprints = {}
814
+
815
+ if not playbook_aggregator_request.rerun:
816
+ # Carry forward unchanged fingerprints from previous state
817
+ prev_fps = mgr.get_cluster_fingerprints(
818
+ name=playbook_name, version=self.agent_version
819
+ )
820
+ current_fp_set = set()
821
+ for cluster_playbooks in clusters.values():
822
+ fp = self._compute_cluster_fingerprint(cluster_playbooks)
823
+ current_fp_set.add(fp)
824
+
825
+ changed_fp_set = set()
826
+ for cluster_playbooks in changed_clusters.values():
827
+ changed_fp_set.add(
828
+ self._compute_cluster_fingerprint(cluster_playbooks)
829
+ )
830
+
831
+ # Carry forward unchanged clusters (still exist and not changed)
832
+ new_fingerprints.update(
833
+ {
834
+ fp: fp_data
835
+ for fp, fp_data in prev_fps.items()
836
+ if fp in current_fp_set and fp not in changed_fp_set
837
+ }
838
+ )
839
+
840
+ # Initialize changed cluster fingerprints before assigning saved IDs
841
+ # below. ``generated_pairs`` preserves the exact source cluster for
842
+ # every non-duplicate playbook the LLM produced.
843
+ for cluster_playbooks in changed_clusters.values():
844
+ fp = self._compute_cluster_fingerprint(cluster_playbooks)
845
+ raw_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
846
+
847
+ new_fingerprints[fp] = {
848
+ "agent_playbook_id": None,
849
+ "user_playbook_ids": raw_ids,
850
+ }
851
+
852
+ saved_playbook_list = list(saved_playbooks)
853
+
854
+ # Assign saved playbook ids to the exact source cluster that generated them.
855
+ for saved_fb, (_, cluster_playbooks) in zip(
856
+ saved_playbook_list, generated_pairs, strict=False
857
+ ):
858
+ if saved_fb and saved_fb.agent_playbook_id:
859
+ fp_key = self._compute_cluster_fingerprint(cluster_playbooks)
860
+ raw_ids = sorted(fb.user_playbook_id for fb in cluster_playbooks)
861
+ new_fingerprints[fp_key] = {
862
+ "agent_playbook_id": saved_fb.agent_playbook_id,
863
+ "user_playbook_ids": raw_ids,
864
+ }
865
+ self.storage.set_source_windows_for_agent_playbook( # type: ignore[reportOptionalMemberAccess]
866
+ saved_fb.agent_playbook_id,
867
+ [
868
+ AgentPlaybookSourceWindow(
869
+ user_playbook_id=fb.user_playbook_id,
870
+ source_interaction_ids=list(fb.source_interaction_ids),
871
+ )
872
+ for fb in sorted(
873
+ cluster_playbooks,
874
+ key=lambda item: item.user_playbook_id,
875
+ )
876
+ ],
877
+ )
878
+
879
+ # Store fingerprints in operation state
880
+ mgr.update_cluster_fingerprints(
881
+ name=playbook_name,
882
+ version=self.agent_version,
883
+ fingerprints=new_fingerprints,
884
+ )
885
+
886
+ # Update operation state with the highest user_playbook_id processed
887
+ self._update_operation_state(playbook_name, user_playbooks)
888
+
889
+ # Build and save change log
890
+ try:
891
+ change_log = self._build_change_log(
892
+ playbook_name=playbook_name,
893
+ full_archive=full_archive,
894
+ before_playbooks_by_id=before_playbooks_by_id,
895
+ saved_playbooks=saved_playbook_list,
896
+ archived_playbook_ids=archived_playbook_ids,
897
+ prev_fingerprints=(prev_fingerprints if not full_archive else {}),
898
+ )
899
+ self.storage.add_playbook_aggregation_change_log(change_log) # type: ignore[reportOptionalMemberAccess]
900
+ logger.info(
901
+ "User playbook aggregation change log for '%s' (agent_version=%s): %d agent playbooks added, %d removed, %d updated",
902
+ playbook_name,
903
+ self.agent_version,
904
+ len(change_log.added_agent_playbooks),
905
+ len(change_log.removed_agent_playbooks),
906
+ len(change_log.updated_agent_playbooks),
907
+ )
908
+ except Exception:
909
+ logger.exception(
910
+ "Failed to save playbook aggregation change log for '%s', continuing",
911
+ playbook_name,
912
+ )
913
+
914
+ # Delete archived playbooks after successful aggregation
915
+ if full_archive:
916
+ for name in full_archive_playbook_names:
917
+ self.storage.delete_archived_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
918
+ name, agent_version=self.agent_version
919
+ )
920
+ elif archived_playbook_ids:
921
+ self.storage.delete_agent_playbooks_by_ids(archived_playbook_ids) # type: ignore[reportOptionalMemberAccess]
922
+
923
+ self._enqueue_playbook_optimization(saved_playbook_list)
924
+
925
+ stats = {
926
+ "clusters_found": len(clusters),
927
+ "user_playbooks_processed": len(user_playbooks),
928
+ "playbooks_generated": len(saved_playbook_list),
929
+ }
930
+ record_usage_event(
931
+ org_id=self.request_context.org_id,
932
+ event_name="aggregation_succeeded",
933
+ event_category="aggregation",
934
+ pipeline="playbook",
935
+ playbook_name=playbook_name,
936
+ agent_version=self.agent_version,
937
+ outcome="success",
938
+ count_value=len(saved_playbook_list),
939
+ duration_ms=int((time.perf_counter() - aggregation_start) * 1000),
940
+ metadata=stats,
941
+ )
942
+ return stats
943
+
944
+ except Exception as e:
945
+ record_usage_event(
946
+ org_id=self.request_context.org_id,
947
+ event_name="aggregation_failed",
948
+ event_category="aggregation",
949
+ pipeline="playbook",
950
+ playbook_name=playbook_name,
951
+ agent_version=self.agent_version,
952
+ outcome="failed",
953
+ duration_ms=int((time.perf_counter() - aggregation_start) * 1000),
954
+ error_kind=type(e).__name__,
955
+ )
956
+ # Restore archived playbooks if any error occurs during aggregation
957
+ logger.error(
958
+ "Error during playbook aggregation for '%s': %s. Restoring archived playbooks.",
959
+ playbook_name,
960
+ str(e),
961
+ )
962
+ if full_archive:
963
+ for name in full_archive_playbook_names:
964
+ self.storage.restore_archived_agent_playbooks_by_playbook_name( # type: ignore[reportOptionalMemberAccess]
965
+ name, agent_version=self.agent_version
966
+ )
967
+ elif archived_playbook_ids:
968
+ self.storage.restore_archived_agent_playbooks_by_ids( # type: ignore[reportOptionalMemberAccess]
969
+ archived_playbook_ids
970
+ )
971
+ # Re-raise the exception after restoring
972
+ raise
973
+
974
+ def get_clusters(
975
+ self,
976
+ user_playbooks: list[UserPlaybook],
977
+ playbook_aggregator_config: PlaybookAggregatorConfig,
978
+ ) -> dict[int, list[UserPlaybook]]:
979
+ """
980
+ Cluster user playbooks based on their embeddings (trigger indexed).
981
+
982
+ Args:
983
+ user_playbooks: Contains user playbooks to cluster
984
+ playbook_aggregator_config: AgentPlaybook aggregator config
985
+
986
+ Returns:
987
+ dict[int, list[UserPlaybook]]: Dictionary mapping cluster IDs to lists of user playbooks
988
+ """
989
+ if not playbook_aggregator_config:
990
+ logger.info(
991
+ "No playbook aggregator config found, skipping playbook aggregation"
992
+ )
993
+ return {}
994
+
995
+ min_cluster_size = playbook_aggregator_config.min_cluster_size
996
+ similarity_threshold = playbook_aggregator_config.clustering_similarity
997
+
998
+ if not user_playbooks:
999
+ logger.info("No user playbooks to cluster")
1000
+ return {}
1001
+
1002
+ # Mock mode: cluster by trigger
1003
+ if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
1004
+ logger.info("Mock mode: clustering by trigger")
1005
+ return self._cluster_by_trigger_mock(user_playbooks, min_cluster_size)
1006
+
1007
+ # Extract embeddings from user playbooks
1008
+ import numpy as np
1009
+ from sklearn.metrics.pairwise import cosine_distances
1010
+
1011
+ embeddings = np.array([playbook.embedding for playbook in user_playbooks])
1012
+
1013
+ if len(embeddings) < min_cluster_size:
1014
+ logger.info(
1015
+ "Not enough playbooks to cluster (got %d, need %d)",
1016
+ len(embeddings),
1017
+ min_cluster_size,
1018
+ )
1019
+ return {}
1020
+
1021
+ # Compute cosine distance matrix for better text embedding clustering
1022
+ distance_matrix = cosine_distances(embeddings)
1023
+
1024
+ # Choose algorithm based on dataset size
1025
+ # Convert similarity threshold to distance threshold (distance = 1 - similarity)
1026
+ distance_threshold = 1.0 - similarity_threshold
1027
+ if len(embeddings) < CLUSTERING_ALGORITHM_THRESHOLD:
1028
+ cluster_labels = self._cluster_with_agglomerative(
1029
+ distance_matrix, min_cluster_size, distance_threshold
1030
+ )
1031
+ else:
1032
+ cluster_labels = self._cluster_with_hdbscan(
1033
+ distance_matrix, min_cluster_size, distance_threshold
1034
+ )
1035
+
1036
+ # Group playbooks by cluster
1037
+ clusters: dict[int, list[UserPlaybook]] = {}
1038
+ for idx, label in enumerate(cluster_labels):
1039
+ if label == -1: # Skip noise points from HDBSCAN
1040
+ continue
1041
+ if label not in clusters:
1042
+ clusters[label] = []
1043
+ clusters[label].append(user_playbooks[idx])
1044
+
1045
+ # Filter out clusters smaller than min_cluster_size
1046
+ clusters = {
1047
+ label: playbooks
1048
+ for label, playbooks in clusters.items()
1049
+ if len(playbooks) >= min_cluster_size
1050
+ }
1051
+
1052
+ logger.info(
1053
+ "Found %d clusters from %d playbooks", len(clusters), len(user_playbooks)
1054
+ )
1055
+ for cluster_id, cluster_playbooks in clusters.items():
1056
+ logger.info("Cluster %d: %d playbooks", cluster_id, len(cluster_playbooks))
1057
+
1058
+ return clusters
1059
+
1060
+ def _cluster_by_trigger_mock(
1061
+ self, user_playbooks: list[UserPlaybook], min_cluster_size: int
1062
+ ) -> dict[int, list[UserPlaybook]]:
1063
+ """
1064
+ Simple mock clustering by exact trigger match.
1065
+
1066
+ Args:
1067
+ user_playbooks: List of user playbooks with trigger field
1068
+ min_cluster_size: Minimum number of playbooks per cluster
1069
+
1070
+ Returns:
1071
+ dict[int, list[UserPlaybook]]: Clusters grouped by trigger
1072
+ """
1073
+ # Group by trigger
1074
+ condition_groups: dict[str, list[UserPlaybook]] = {}
1075
+ for fb in user_playbooks:
1076
+ condition = fb.trigger or ""
1077
+ if condition not in condition_groups:
1078
+ condition_groups[condition] = []
1079
+ condition_groups[condition].append(fb)
1080
+
1081
+ # Convert to cluster format, filtering by min_cluster_size
1082
+ clusters: dict[int, list[UserPlaybook]] = {}
1083
+ cluster_id = 0
1084
+ for playbooks_group in condition_groups.values():
1085
+ if len(playbooks_group) >= min_cluster_size:
1086
+ clusters[cluster_id] = playbooks_group
1087
+ cluster_id += 1
1088
+
1089
+ logger.info(
1090
+ "Mock mode: created %d trigger clusters from %d playbooks",
1091
+ len(clusters),
1092
+ len(user_playbooks),
1093
+ )
1094
+ return clusters
1095
+
1096
+ def _cluster_with_agglomerative(
1097
+ self,
1098
+ distance_matrix: np.ndarray,
1099
+ min_cluster_size: int, # noqa: ARG002
1100
+ distance_threshold: float,
1101
+ ) -> np.ndarray:
1102
+ """
1103
+ Cluster using Agglomerative Clustering - best for small datasets.
1104
+
1105
+ Args:
1106
+ distance_matrix: Precomputed cosine distance matrix
1107
+ min_cluster_size: Minimum cluster size (used for logging only,
1108
+ filtering happens in get_clusters)
1109
+ distance_threshold: Maximum cosine distance to merge clusters (1 - similarity_threshold)
1110
+
1111
+ Returns:
1112
+ np.ndarray: Cluster labels for each point
1113
+ """
1114
+ from sklearn.cluster import AgglomerativeClustering
1115
+
1116
+ logger.info(
1117
+ "Using Agglomerative Clustering for %d playbooks (< %d threshold), distance_threshold=%.2f",
1118
+ len(distance_matrix),
1119
+ CLUSTERING_ALGORITHM_THRESHOLD,
1120
+ distance_threshold,
1121
+ )
1122
+
1123
+ clusterer = AgglomerativeClustering(
1124
+ n_clusters=None, # type: ignore[reportArgumentType]
1125
+ distance_threshold=distance_threshold,
1126
+ metric="precomputed",
1127
+ linkage="average",
1128
+ )
1129
+
1130
+ return clusterer.fit_predict(distance_matrix)
1131
+
1132
+ def _cluster_with_hdbscan(
1133
+ self,
1134
+ distance_matrix: np.ndarray,
1135
+ min_cluster_size: int,
1136
+ distance_threshold: float,
1137
+ ) -> np.ndarray:
1138
+ """
1139
+ Cluster using HDBSCAN - best for large datasets with potential noise.
1140
+
1141
+ Args:
1142
+ distance_matrix: Precomputed cosine distance matrix
1143
+ min_cluster_size: Minimum number of points to form a cluster
1144
+ distance_threshold: Maximum cosine distance for cluster merging (1 - similarity_threshold)
1145
+
1146
+ Returns:
1147
+ np.ndarray: Cluster labels for each point (-1 indicates noise)
1148
+ """
1149
+ import hdbscan
1150
+
1151
+ logger.info(
1152
+ "Using HDBSCAN for %d playbooks (>= %d threshold), distance_threshold=%.2f",
1153
+ len(distance_matrix),
1154
+ CLUSTERING_ALGORITHM_THRESHOLD,
1155
+ distance_threshold,
1156
+ )
1157
+
1158
+ clusterer = hdbscan.HDBSCAN(
1159
+ min_cluster_size=min_cluster_size,
1160
+ min_samples=1,
1161
+ metric="precomputed",
1162
+ cluster_selection_epsilon=distance_threshold,
1163
+ )
1164
+
1165
+ return clusterer.fit_predict(distance_matrix)
1166
+
1167
+ def _generate_playbooks_from_clusters(
1168
+ self,
1169
+ clusters: dict[int, list[UserPlaybook]],
1170
+ existing_approved_playbooks: list[AgentPlaybook],
1171
+ direction_overlap_threshold: float = 0.6,
1172
+ ) -> list[AgentPlaybook]:
1173
+ """
1174
+ Generate playbooks from clusters, considering existing approved playbooks.
1175
+
1176
+ Args:
1177
+ clusters: Dictionary mapping cluster IDs to lists of raw playbooks
1178
+ existing_approved_playbooks: List of existing approved playbooks to avoid duplication
1179
+ direction_overlap_threshold: Token overlap threshold for grouping by direction
1180
+
1181
+ Returns:
1182
+ list[AgentPlaybook]: List of newly generated playbooks (excludes duplicates)
1183
+ """
1184
+ return [
1185
+ playbook
1186
+ for playbook, _ in self._generate_playbooks_with_source_clusters(
1187
+ clusters,
1188
+ existing_approved_playbooks,
1189
+ direction_overlap_threshold=direction_overlap_threshold,
1190
+ )
1191
+ ]
1192
+
1193
+ def _generate_playbooks_with_source_clusters(
1194
+ self,
1195
+ clusters: dict[int, list[UserPlaybook]],
1196
+ existing_approved_playbooks: list[AgentPlaybook],
1197
+ direction_overlap_threshold: float = 0.6,
1198
+ ) -> list[tuple[AgentPlaybook, list[UserPlaybook]]]:
1199
+ """Generate agent playbooks while preserving their exact source cluster."""
1200
+ # Format existing approved playbooks for the prompt
1201
+ approved_playbooks_str = (
1202
+ "\n".join([f"- {fb.content}" for fb in existing_approved_playbooks])
1203
+ if existing_approved_playbooks
1204
+ else "None"
1205
+ )
1206
+
1207
+ new_playbooks: list[tuple[AgentPlaybook, list[UserPlaybook]]] = []
1208
+ for cluster_playbooks in clusters.values():
1209
+ playbook = self._generate_playbook_from_cluster(
1210
+ cluster_playbooks,
1211
+ approved_playbooks_str,
1212
+ direction_overlap_threshold=direction_overlap_threshold,
1213
+ )
1214
+ if playbook is not None:
1215
+ new_playbooks.append((playbook, cluster_playbooks))
1216
+ return new_playbooks
1217
+
1218
+ def _enqueue_playbook_optimization(
1219
+ self, saved_playbooks: Sequence[AgentPlaybook | None]
1220
+ ) -> None:
1221
+ config = self.configurator.get_config().playbook_optimizer_config
1222
+ if (
1223
+ getattr(config, "enabled", False) is not True
1224
+ or getattr(config, "optimize_agent_playbooks", False) is not True
1225
+ or not saved_playbooks
1226
+ ):
1227
+ return
1228
+ from reflexio.server.services.playbook_optimizer import (
1229
+ PlaybookOptimizationScheduler,
1230
+ PlaybookOptimizationTarget,
1231
+ PlaybookOptimizer,
1232
+ )
1233
+
1234
+ scheduler = PlaybookOptimizationScheduler.get_instance()
1235
+ for playbook in saved_playbooks:
1236
+ if (
1237
+ playbook is None
1238
+ or not playbook.agent_playbook_id
1239
+ or playbook.status is not None
1240
+ or playbook.playbook_status != PlaybookStatus.PENDING
1241
+ ):
1242
+ continue
1243
+ target = PlaybookOptimizationTarget(
1244
+ kind="agent_playbook", target_id=playbook.agent_playbook_id
1245
+ )
1246
+ scheduler.enqueue(
1247
+ org_id=self.request_context.org_id,
1248
+ target=target,
1249
+ callback=lambda target=target: PlaybookOptimizer(
1250
+ self.request_context, self.client
1251
+ ).optimize(target),
1252
+ jitter_seconds=config.scheduler_jitter_seconds,
1253
+ abort_cooldown_threshold=config.abort_cooldown_threshold,
1254
+ cooldown_after_aborts_seconds=config.cooldown_after_aborts_seconds,
1255
+ )
1256
+
1257
+ def _generate_playbook_from_cluster(
1258
+ self,
1259
+ cluster_playbooks: list[UserPlaybook],
1260
+ existing_approved_playbooks_str: str,
1261
+ direction_overlap_threshold: float = 0.6,
1262
+ ) -> AgentPlaybook | None:
1263
+ """
1264
+ Generate a playbook from a cluster using structured JSON output.
1265
+
1266
+ Args:
1267
+ cluster_playbooks: List of raw playbooks in this cluster
1268
+ existing_approved_playbooks_str: Formatted string of existing approved playbooks
1269
+ direction_overlap_threshold: Token overlap threshold for grouping by direction
1270
+
1271
+ Returns:
1272
+ AgentPlaybook | None: Generated playbook, or None if no new playbook needed
1273
+ """
1274
+ if not cluster_playbooks:
1275
+ return None
1276
+
1277
+ if os.getenv("MOCK_LLM_RESPONSE", "").lower() == "true":
1278
+ # Extract structured fields directly from cluster
1279
+ triggers = [fb.trigger for fb in cluster_playbooks if fb.trigger]
1280
+
1281
+ trigger = triggers[0] if triggers else "in general"
1282
+
1283
+ # Fall back to using content from first playbook if available
1284
+ first_content = cluster_playbooks[0].content
1285
+ if not first_content:
1286
+ logger.info("No valid content in cluster, skipping")
1287
+ return None
1288
+
1289
+ # Build content directly as a freeform summary
1290
+ content_text = f"When {trigger}, {first_content}."
1291
+
1292
+ return AgentPlaybook(
1293
+ playbook_name=cluster_playbooks[0].playbook_name,
1294
+ agent_version=cluster_playbooks[0].agent_version,
1295
+ content=content_text,
1296
+ trigger=trigger,
1297
+ playbook_status=PlaybookStatus.PENDING,
1298
+ playbook_metadata="mock_generated",
1299
+ )
1300
+
1301
+ # Format raw playbooks for prompt using structured format
1302
+ raw_playbooks_str = self._format_structured_cluster_input(
1303
+ cluster_playbooks,
1304
+ direction_overlap_threshold=direction_overlap_threshold,
1305
+ )
1306
+
1307
+ messages = [
1308
+ {
1309
+ "role": "user",
1310
+ "content": self.request_context.prompt_manager.render_prompt(
1311
+ PlaybookServiceConstants.PLAYBOOK_AGGREGATION_PROMPT_ID,
1312
+ {
1313
+ "user_playbooks": raw_playbooks_str,
1314
+ "existing_approved_playbooks": existing_approved_playbooks_str,
1315
+ },
1316
+ ),
1317
+ }
1318
+ ]
1319
+
1320
+ try:
1321
+ response = self.client.generate_chat_response(
1322
+ messages=messages,
1323
+ model=self.client.config.model,
1324
+ response_format=PlaybookAggregationOutput,
1325
+ parse_structured_output=True,
1326
+ )
1327
+ log_model_response(logger, "Aggregation structured response", response)
1328
+
1329
+ if not isinstance(response, PlaybookAggregationOutput):
1330
+ logger.warning(
1331
+ "LLM response was not parsed as PlaybookAggregationOutput (got %s), returning None.",
1332
+ type(response).__name__,
1333
+ )
1334
+ return None
1335
+
1336
+ return self._process_aggregation_response(response, cluster_playbooks)
1337
+ except Exception as exc:
1338
+ logger.error(
1339
+ "AgentPlaybook aggregation failed due to %s, returning None.",
1340
+ str(exc),
1341
+ )
1342
+ return None
1343
+
1344
+ def _process_aggregation_response(
1345
+ self, response: PlaybookAggregationOutput, cluster_playbooks: list[UserPlaybook]
1346
+ ) -> AgentPlaybook | None:
1347
+ """
1348
+ Process structured response from LLM into AgentPlaybook.
1349
+
1350
+ Args:
1351
+ response: Parsed PlaybookAggregationOutput from LLM
1352
+ cluster_playbooks: Original cluster playbooks for metadata
1353
+
1354
+ Returns:
1355
+ AgentPlaybook or None if no playbook should be generated
1356
+ """
1357
+ if not response:
1358
+ return None
1359
+
1360
+ structured = response.playbook
1361
+ if structured is None:
1362
+ logger.info("LLM returned null playbook (duplicate of existing)")
1363
+ return None
1364
+
1365
+ # content is always the LLM's freeform summary;
1366
+ # fall back to formatted structured fields for backward compatibility
1367
+ playbook_content = ensure_playbook_content(structured.content, structured)
1368
+ logger.info(
1369
+ "Aggregated playbook content (freeform): %.200s",
1370
+ playbook_content,
1371
+ )
1372
+
1373
+ return AgentPlaybook(
1374
+ playbook_name=cluster_playbooks[0].playbook_name,
1375
+ agent_version=cluster_playbooks[0].agent_version,
1376
+ content=playbook_content,
1377
+ trigger=structured.trigger,
1378
+ rationale=structured.rationale,
1379
+ playbook_status=PlaybookStatus.PENDING,
1380
+ playbook_metadata="",
1381
+ )
1382
+
1383
+ def _get_playbook_aggregator_config(self) -> PlaybookAggregatorConfig | None:
1384
+ root_config = self.configurator.get_config()
1385
+ playbook_config = getattr(root_config, "user_playbook_extractor_config", None)
1386
+ if not playbook_config:
1387
+ return None
1388
+ return playbook_config.aggregation_config