claude-smart 0.2.47 → 0.2.49

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 (139) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +6 -2
  3. package/bin/claude-smart.js +289 -56
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  8. package/plugin/dashboard/package-lock.json +61 -390
  9. package/plugin/dashboard/package.json +2 -2
  10. package/plugin/opencode/dist/server.mjs +60 -2
  11. package/plugin/opencode/server.mts +64 -2
  12. package/plugin/pyproject.toml +2 -2
  13. package/plugin/scripts/_lib.sh +58 -6
  14. package/plugin/scripts/backend-service.sh +15 -10
  15. package/plugin/scripts/codex-hook.js +16 -4
  16. package/plugin/scripts/dashboard-service.sh +1 -1
  17. package/plugin/scripts/ensure-plugin-root.sh +106 -8
  18. package/plugin/scripts/opencode-claude-compat.js +8 -1
  19. package/plugin/scripts/smart-install.sh +101 -20
  20. package/plugin/src/README.md +1 -1
  21. package/plugin/src/claude_smart/cli.py +79 -29
  22. package/plugin/src/claude_smart/env_config.py +53 -11
  23. package/plugin/uv.lock +5 -5
  24. package/plugin/vendor/reflexio/.env.example +7 -0
  25. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  26. package/plugin/vendor/reflexio/reflexio/README.md +8 -5
  27. package/plugin/vendor/reflexio/reflexio/cli/README.md +3 -0
  28. package/plugin/vendor/reflexio/reflexio/client/client.py +40 -6
  29. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  30. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  31. package/plugin/vendor/reflexio/reflexio/lib/_search.py +15 -11
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/entities.py +18 -0
  33. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  34. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +24 -3
  35. package/plugin/vendor/reflexio/reflexio/server/README.md +25 -8
  36. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  37. package/plugin/vendor/reflexio/reflexio/server/api.py +148 -3277
  38. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  39. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  40. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  41. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  42. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  43. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  44. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  45. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  46. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  47. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  48. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +72 -1817
  49. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +56 -4
  50. package/plugin/vendor/reflexio/reflexio/server/llm/providers/local_embedding_provider.py +183 -1
  51. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  52. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  53. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  54. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  55. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  56. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  57. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  58. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  59. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  60. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +320 -0
  61. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  62. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  63. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  64. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  66. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -2
  68. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  73. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  74. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  76. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  77. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/__init__.py +13 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/scheduler.py +142 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/durable_learning/worker.py +193 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +24 -41
  81. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +335 -113
  82. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +362 -81
  83. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +68 -490
  84. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  87. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  88. package/plugin/vendor/reflexio/reflexio/server/services/profile/service.py +44 -22
  89. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +31 -4
  91. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  92. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +196 -353
  93. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +0 -1513
  94. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_learning_jobs.py +379 -0
  95. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +17 -6
  96. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1281
  97. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +8 -3
  98. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  102. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  103. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  104. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +216 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +22 -10
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +8 -3
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +25 -12
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +308 -0
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +920 -0
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -8
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_commit_scope.py +9 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_learning_jobs.py +219 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +95 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +60 -80
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +0 -148
@@ -10,6 +10,7 @@ import uuid
10
10
  from collections.abc import Callable
11
11
  from concurrent.futures import ThreadPoolExecutor
12
12
  from concurrent.futures import TimeoutError as FuturesTimeoutError
13
+ from contextlib import nullcontext
13
14
  from dataclasses import dataclass, field
14
15
  from typing import TYPE_CHECKING
15
16
 
@@ -21,7 +22,9 @@ from reflexio.models.api_schema.service_schemas import (
21
22
  )
22
23
  from reflexio.models.config_schema import Config
23
24
  from reflexio.server.api_endpoints.request_context import RequestContext
25
+ from reflexio.server.env_utils import env_str, env_truthy
24
26
  from reflexio.server.llm.litellm_client import LiteLLMClient
27
+ from reflexio.server.operation_limiter import operation_limit
25
28
  from reflexio.server.services.agent_success_evaluation.runner import (
26
29
  run_group_evaluation,
27
30
  )
@@ -140,7 +143,12 @@ class GenerationService:
140
143
  # ===============================
141
144
 
142
145
  def run(
143
- self, publish_user_interaction_request: PublishUserInteractionRequest
146
+ self,
147
+ publish_user_interaction_request: PublishUserInteractionRequest,
148
+ *,
149
+ use_publish_limiter: bool = True,
150
+ publish_limiter_wait_forever: bool = True,
151
+ defer_learning: bool = False,
144
152
  ) -> GenerationServiceResult:
145
153
  """
146
154
  Process a user interaction request by storing interactions and triggering generation services.
@@ -156,6 +164,14 @@ class GenerationService:
156
164
 
157
165
  Args:
158
166
  publish_user_interaction_request: The incoming user interaction request
167
+ use_publish_limiter: Whether this service should throttle the post-write
168
+ learning pipeline. Server sync calls acquire the bounded API
169
+ limiter before invoking this service, so they disable the inner
170
+ limiter to avoid waiting after storage side effects.
171
+ publish_limiter_wait_forever: Whether to queue indefinitely for the
172
+ post-write learning limiter when ``use_publish_limiter`` is true.
173
+ defer_learning: Whether to persist the publish and enqueue learning
174
+ for a process-local worker instead of running it inline.
159
175
 
160
176
  Returns:
161
177
  GenerationServiceResult: The request_id assigned to this publish call
@@ -234,17 +250,32 @@ class GenerationService:
234
250
  session_id=publish_user_interaction_request.session_id,
235
251
  evaluation_only=publish_user_interaction_request.evaluation_only,
236
252
  )
237
- self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
238
253
 
239
- # Add interactions to storage (bulk insert with batched embedding generation)
240
- self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
241
- user_id=user_id, interactions=new_interactions
254
+ # When the durable queue is enabled and this is a deferred publish, the
255
+ # request + interactions + job row are written together inside a single
256
+ # commit_scope (below). Every other path persists here unconditionally.
257
+ use_durable_queue = env_truthy(
258
+ env_str("REFLEXIO_DURABLE_LEARNING_QUEUE", "false")
242
259
  )
260
+ _durable_defer = defer_learning and use_durable_queue
261
+
262
+ if not _durable_defer:
263
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
264
+ # Add interactions to storage (bulk insert with batched embedding generation)
265
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
266
+ user_id=user_id, interactions=new_interactions
267
+ )
243
268
 
244
269
  # Extract source (empty string treated as None)
245
270
  source = publish_user_interaction_request.source or None
246
271
 
247
272
  if publish_user_interaction_request.evaluation_only:
273
+ if _durable_defer:
274
+ # evaluation_only returns early; ensure data lands even on durable path.
275
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
276
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
277
+ user_id=user_id, interactions=new_interactions
278
+ )
248
279
  self._schedule_group_evaluation_if_needed(
249
280
  new_request=new_request,
250
281
  user_id=user_id,
@@ -275,6 +306,12 @@ class GenerationService:
275
306
  not publish_user_interaction_request.override_learning_stall
276
307
  and (stall_warning := self._active_learning_stall_warning()) is not None
277
308
  ):
309
+ if _durable_defer:
310
+ # Stall skips learning but data must still land.
311
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
312
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
313
+ user_id=user_id, interactions=new_interactions
314
+ )
278
315
  result.warnings.append(stall_warning)
279
316
  logger.warning("%s; skipping automatic extraction", stall_warning)
280
317
  record_usage_event(
@@ -293,51 +330,295 @@ class GenerationService:
293
330
  )
294
331
  return result
295
332
 
296
- # Reflection runs as its own sliding-window step BEFORE the
297
- # extractor pool spins up, so any replacements it makes are
298
- # visible to the extractors when they retrieve existing
299
- # profile/playbook context. Wrapped in a broad except so a
300
- # reflection bug never breaks the publish.
301
- self._maybe_run_reflection(
302
- user_id=user_id,
303
- request_id=request_id,
304
- agent_version=agent_version,
305
- source=source,
306
- )
333
+ if defer_learning:
334
+ if _durable_defer:
335
+ # Durable atomic path: pre-generate embeddings OUTSIDE the
336
+ # transaction (network call), then persist request + interactions
337
+ # + job in one commit_scope.
338
+ self.storage.prepare_interaction_embeddings(new_interactions) # type: ignore[reportOptionalMemberAccess]
339
+ covers_through = max(i.created_at for i in new_interactions)
340
+ with self.storage.commit_scope(): # type: ignore[reportOptionalMemberAccess]
341
+ self.storage.add_request(new_request) # type: ignore[reportOptionalMemberAccess]
342
+ self.storage.add_user_interactions_bulk( # type: ignore[reportOptionalMemberAccess]
343
+ user_id=user_id,
344
+ interactions=new_interactions,
345
+ embeddings_prepared=True,
346
+ )
347
+ self.storage.enqueue_learning_job( # type: ignore[reportOptionalMemberAccess]
348
+ org_id=self.org_id,
349
+ user_id=user_id,
350
+ request_id=request_id,
351
+ covers_through=covers_through,
352
+ force_extraction=publish_user_interaction_request.force_extraction,
353
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
354
+ )
355
+ self._schedule_group_evaluation_if_needed(
356
+ new_request=new_request,
357
+ user_id=user_id,
358
+ agent_version=agent_version,
359
+ source=source,
360
+ )
361
+ record_usage_event(
362
+ org_id=self.org_id,
363
+ user_id=user_id,
364
+ request_id=request_id,
365
+ session_id=new_request.session_id,
366
+ source=source,
367
+ agent_version=agent_version,
368
+ backend="durable",
369
+ event_name="publish_request_succeeded",
370
+ event_category="publish",
371
+ outcome="success",
372
+ count_value=len(new_interactions),
373
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
374
+ metadata={
375
+ "defer_learning": True,
376
+ "durable_queue": True,
377
+ "warning_count": len(result.warnings),
378
+ },
379
+ )
380
+ return result
381
+
382
+ from reflexio.server.services.publish_learning_worker import (
383
+ PublishLearningJob,
384
+ enqueue_publish_learning,
385
+ )
307
386
 
308
- # Create generation services and requests
309
- # Each service writes to separate storage tables and has no dependencies on others
310
- profile_generation_service = ProfileGenerationService(
311
- llm_client=self.client, request_context=self.request_context
312
- )
313
- profile_generation_request = ProfileGenerationRequest(
314
- user_id=user_id,
315
- request_id=request_id,
316
- source=source,
317
- force_extraction=publish_user_interaction_request.force_extraction,
318
- )
387
+ self._schedule_group_evaluation_if_needed(
388
+ new_request=new_request,
389
+ user_id=user_id,
390
+ agent_version=agent_version,
391
+ source=source,
392
+ )
393
+ learning_queued = enqueue_publish_learning(
394
+ PublishLearningJob(
395
+ org_id=self.org_id,
396
+ user_id=user_id,
397
+ request_id=request_id,
398
+ session_id=new_request.session_id,
399
+ source=source,
400
+ agent_version=agent_version,
401
+ force_extraction=publish_user_interaction_request.force_extraction,
402
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
403
+ )
404
+ )
405
+ record_usage_event(
406
+ org_id=self.org_id,
407
+ user_id=user_id,
408
+ request_id=request_id,
409
+ session_id=new_request.session_id,
410
+ source=source,
411
+ agent_version=agent_version,
412
+ backend="classic",
413
+ event_name="publish_request_succeeded",
414
+ event_category="publish",
415
+ outcome="success",
416
+ count_value=len(new_interactions),
417
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
418
+ metadata={
419
+ "defer_learning": True,
420
+ "learning_queued": learning_queued,
421
+ "warning_count": len(result.warnings),
422
+ },
423
+ )
424
+ return result
319
425
 
320
- playbook_generation_service = PlaybookGenerationService(
321
- llm_client=self.client,
322
- request_context=self.request_context,
323
- skip_aggregation=publish_user_interaction_request.skip_aggregation,
426
+ # The durable write above must not sit behind the publish limiter:
427
+ # async API callers have already received success=True. Throttle only
428
+ # the post-write learning pipeline so a hung LLM cannot silently drop
429
+ # later acknowledged publishes before they reach storage.
430
+ learning_limit = (
431
+ operation_limit(
432
+ self.org_id,
433
+ "publish",
434
+ wait_forever=publish_limiter_wait_forever,
435
+ )
436
+ if use_publish_limiter
437
+ else nullcontext()
324
438
  )
325
- playbook_generation_request = PlaybookGenerationRequest(
326
- request_id=request_id,
327
- agent_version=agent_version,
439
+ with learning_limit:
440
+ self._run_learning_steps(
441
+ user_id=user_id,
442
+ request_id=request_id,
443
+ agent_version=agent_version,
444
+ force_extraction=publish_user_interaction_request.force_extraction,
445
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
446
+ source=source,
447
+ result=result,
448
+ )
449
+
450
+ # Schedule delayed group evaluation for the required session.
451
+ self._schedule_group_evaluation_if_needed(
452
+ new_request=new_request,
453
+ user_id=user_id,
454
+ agent_version=agent_version,
455
+ source=source,
456
+ )
457
+
458
+ record_usage_event(
459
+ org_id=self.org_id,
460
+ user_id=user_id,
461
+ request_id=request_id,
462
+ session_id=new_request.session_id,
463
+ source=source,
464
+ agent_version=agent_version,
465
+ backend="classic",
466
+ event_name="publish_request_succeeded",
467
+ event_category="publish",
468
+ outcome="success",
469
+ count_value=len(new_interactions),
470
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
471
+ metadata={"warning_count": len(result.warnings)},
472
+ )
473
+ return result
474
+
475
+ except Exception as e:
476
+ record_usage_event(
477
+ org_id=self.org_id,
328
478
  user_id=user_id,
329
- source=source,
330
- force_extraction=publish_user_interaction_request.force_extraction,
479
+ request_id=result.request_id,
480
+ session_id=publish_user_interaction_request.session_id,
481
+ source=publish_user_interaction_request.source,
482
+ agent_version=agent_version,
483
+ event_name="publish_request_failed",
484
+ event_category="publish",
485
+ outcome="failed",
486
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
487
+ error_kind=type(e).__name__,
331
488
  )
489
+ with sentry_tags(
490
+ subsystem="generation",
491
+ op="refresh_profile",
492
+ org_id=self.org_id,
493
+ user_id=user_id,
494
+ request_id=result.request_id,
495
+ error_type=type(e).__name__,
496
+ ):
497
+ logger.exception(
498
+ "Failed to refresh user profile for user id: %s",
499
+ user_id,
500
+ )
501
+ raise
502
+
503
+ # ===============================
504
+ # deferred learning
505
+ # ===============================
506
+
507
+ def run_deferred_learning(
508
+ self,
509
+ *,
510
+ user_id: str,
511
+ request_id: str,
512
+ session_id: str | None, # noqa: ARG002 - kept for worker telemetry symmetry
513
+ source: str | None,
514
+ agent_version: str,
515
+ force_extraction: bool,
516
+ skip_aggregation: bool,
517
+ sequential: bool = False,
518
+ ) -> GenerationServiceResult:
519
+ """Run the learning steps for a deferred job.
520
+
521
+ Args:
522
+ sequential: When True, run profile and playbook generation on the
523
+ calling thread (no ThreadPoolExecutor). Required when the
524
+ caller already holds the storage commit_scope lock — spawning
525
+ worker threads inside that scope would deadlock on the RLock.
526
+ """
527
+ result = GenerationServiceResult(request_id=request_id)
528
+ self._run_learning_steps(
529
+ user_id=user_id,
530
+ request_id=request_id,
531
+ agent_version=agent_version,
532
+ force_extraction=force_extraction,
533
+ skip_aggregation=skip_aggregation,
534
+ source=source,
535
+ result=result,
536
+ parallel=not sequential,
537
+ )
538
+ return result
539
+
540
+ # ===============================
541
+ # private methods
542
+ # ===============================
332
543
 
333
- # Run profile and playbook generation services in parallel
334
- # Each service creates its own internal ThreadPoolExecutor for extractors
335
- # This is safe because we create separate, independent pool instances
336
- # Uses manual executor management to avoid blocking on shutdown(wait=True)
337
- # when threads are hung on LLM calls
544
+ def _run_learning_steps(
545
+ self,
546
+ *,
547
+ user_id: str,
548
+ request_id: str,
549
+ agent_version: str,
550
+ force_extraction: bool,
551
+ skip_aggregation: bool,
552
+ source: str | None,
553
+ result: GenerationServiceResult,
554
+ parallel: bool = True,
555
+ ) -> None:
556
+ # Reflection runs as its own sliding-window step BEFORE the extractor
557
+ # pool spins up, so replacements are visible to extractors.
558
+ self._maybe_run_reflection(
559
+ user_id=user_id,
560
+ request_id=request_id,
561
+ agent_version=agent_version,
562
+ source=source,
563
+ )
564
+
565
+ profile_generation_service = ProfileGenerationService(
566
+ llm_client=self.client, request_context=self.request_context
567
+ )
568
+ profile_generation_request = ProfileGenerationRequest(
569
+ user_id=user_id,
570
+ request_id=request_id,
571
+ source=source,
572
+ force_extraction=force_extraction,
573
+ )
574
+
575
+ playbook_generation_service = PlaybookGenerationService(
576
+ llm_client=self.client,
577
+ request_context=self.request_context,
578
+ skip_aggregation=skip_aggregation,
579
+ )
580
+ playbook_generation_request = PlaybookGenerationRequest(
581
+ request_id=request_id,
582
+ agent_version=agent_version,
583
+ user_id=user_id,
584
+ source=source,
585
+ force_extraction=force_extraction,
586
+ )
587
+
588
+ if not parallel:
589
+ # Sequential mode: run both services on the calling thread.
590
+ # Required when the caller holds a commit_scope lock — spawning
591
+ # child threads inside that scope would deadlock the SQLite RLock.
592
+ for svc_name, _run in (
593
+ (
594
+ "profile_generation",
595
+ lambda: profile_generation_service.run(profile_generation_request),
596
+ ),
597
+ (
598
+ "playbook_generation",
599
+ lambda: playbook_generation_service.run(
600
+ playbook_generation_request
601
+ ),
602
+ ),
603
+ ):
604
+ try:
605
+ _run()
606
+ except Exception as e:
607
+ msg = f"{svc_name} failed: {e}"
608
+ with sentry_tags(
609
+ subsystem="generation",
610
+ service=svc_name,
611
+ request_id=request_id,
612
+ error_type=type(e).__name__,
613
+ ):
614
+ logger.exception(
615
+ "Generation service failed for request %s",
616
+ request_id,
617
+ )
618
+ result.warnings.append(msg)
619
+ else:
338
620
  executor = ThreadPoolExecutor(max_workers=2)
339
621
  try:
340
- # Each thread needs its own context copy — Context.run() is non-reentrant
341
622
  futures = [
342
623
  executor.submit(
343
624
  contextvars.copy_context().run,
@@ -351,14 +632,15 @@ class GenerationService:
351
632
  ),
352
633
  ]
353
634
 
354
- # Collect results and handle any exceptions
355
- # Each service failure is logged but doesn't block others
356
635
  service_names = ["profile_generation", "playbook_generation"]
357
636
  for future, service_name in zip(futures, service_names, strict=True):
358
637
  try:
359
638
  future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
360
639
  except FuturesTimeoutError: # noqa: PERF203
361
- msg = f"{service_name} timed out after {GENERATION_SERVICE_TIMEOUT_SECONDS}s"
640
+ msg = (
641
+ f"{service_name} timed out after "
642
+ f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
643
+ )
362
644
  with sentry_tags(
363
645
  subsystem="generation",
364
646
  service=service_name,
@@ -383,79 +665,19 @@ class GenerationService:
383
665
  finally:
384
666
  executor.shutdown(wait=False, cancel_futures=True)
385
667
 
386
- # Tagging runs an LLM call per newly generated entity, so defer it off
387
- # the publish path. The scheduler debounces bursts and is idempotent
388
- # (already-tagged entities are skipped).
389
- try:
390
- schedule_tagging(
391
- org_id=self.org_id,
392
- user_id=user_id,
393
- agent_version=agent_version,
394
- request_context=self.request_context,
395
- llm_client=self.client,
396
- )
397
- except Exception:
398
- logger.exception(
399
- "Failed to schedule tagging for publish request %s",
400
- request_id,
401
- )
402
-
403
- # Schedule delayed group evaluation for the required session.
404
- self._schedule_group_evaluation_if_needed(
405
- new_request=new_request,
406
- user_id=user_id,
407
- agent_version=agent_version,
408
- source=source,
409
- )
410
-
411
- record_usage_event(
668
+ try:
669
+ schedule_tagging(
412
670
  org_id=self.org_id,
413
671
  user_id=user_id,
414
- request_id=request_id,
415
- session_id=new_request.session_id,
416
- source=source,
417
672
  agent_version=agent_version,
418
- backend="classic",
419
- event_name="publish_request_succeeded",
420
- event_category="publish",
421
- outcome="success",
422
- count_value=len(new_interactions),
423
- duration_ms=int((time.perf_counter() - publish_start) * 1000),
424
- metadata={"warning_count": len(result.warnings)},
673
+ request_context=self.request_context,
674
+ llm_client=self.client,
425
675
  )
426
- return result
427
-
428
- except Exception as e:
429
- record_usage_event(
430
- org_id=self.org_id,
431
- user_id=user_id,
432
- request_id=result.request_id,
433
- session_id=publish_user_interaction_request.session_id,
434
- source=publish_user_interaction_request.source,
435
- agent_version=agent_version,
436
- event_name="publish_request_failed",
437
- event_category="publish",
438
- outcome="failed",
439
- duration_ms=int((time.perf_counter() - publish_start) * 1000),
440
- error_kind=type(e).__name__,
676
+ except Exception:
677
+ logger.exception(
678
+ "Failed to schedule tagging for publish request %s",
679
+ request_id,
441
680
  )
442
- with sentry_tags(
443
- subsystem="generation",
444
- op="refresh_profile",
445
- org_id=self.org_id,
446
- user_id=user_id,
447
- request_id=result.request_id,
448
- error_type=type(e).__name__,
449
- ):
450
- logger.exception(
451
- "Failed to refresh user profile for user id: %s",
452
- user_id,
453
- )
454
- raise
455
-
456
- # ===============================
457
- # private methods
458
- # ===============================
459
681
 
460
682
  def _schedule_group_evaluation_if_needed(
461
683
  self,