claude-smart 0.2.46 → 0.2.48

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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  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/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +36 -2
  10. package/plugin/dashboard/package-lock.json +61 -390
  11. package/plugin/dashboard/package.json +2 -2
  12. package/plugin/opencode/dist/server.mjs +76 -2
  13. package/plugin/opencode/server.mts +79 -2
  14. package/plugin/pyproject.toml +6 -2
  15. package/plugin/scripts/smart-install.sh +7 -1
  16. package/plugin/src/claude_smart/cli.py +210 -22
  17. package/plugin/src/claude_smart/context_format.py +9 -9
  18. package/plugin/src/claude_smart/cs_cite.py +66 -19
  19. package/plugin/uv.lock +5 -5
  20. package/plugin/vendor/reflexio/.env.example +7 -0
  21. package/plugin/vendor/reflexio/README.md +3 -3
  22. package/plugin/vendor/reflexio/pyproject.toml +2 -1
  23. package/plugin/vendor/reflexio/reflexio/README.md +11 -6
  24. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  25. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  26. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  27. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  28. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  29. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  30. package/plugin/vendor/reflexio/reflexio/lib/_config.py +23 -18
  31. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  32. package/plugin/vendor/reflexio/reflexio/lib/_interactions.py +16 -1
  33. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  34. package/plugin/vendor/reflexio/reflexio/lib/_search.py +27 -5
  35. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  36. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  37. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/enums.py +1 -0
  38. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  39. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  40. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +37 -0
  41. package/plugin/vendor/reflexio/reflexio/server/README.md +38 -9
  42. package/plugin/vendor/reflexio/reflexio/server/__init__.py +21 -2
  43. package/plugin/vendor/reflexio/reflexio/server/api.py +274 -3267
  44. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/README.md +4 -3
  45. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/publisher_api.py +16 -1
  46. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  47. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  48. package/plugin/vendor/reflexio/reflexio/server/cache/reflexio_cache.py +62 -36
  49. package/plugin/vendor/reflexio/reflexio/server/deployment_profile.py +69 -0
  50. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  51. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_embedding.py +424 -0
  52. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_json_extraction.py +249 -0
  53. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_structured_output.py +195 -0
  54. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_subprocess.py +152 -0
  55. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_text_generation.py +980 -0
  56. package/plugin/vendor/reflexio/reflexio/server/llm/_litellm_types.py +110 -0
  57. package/plugin/vendor/reflexio/reflexio/server/llm/litellm_client.py +73 -1819
  58. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  59. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +57 -5
  60. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  61. package/plugin/vendor/reflexio/reflexio/server/middleware.py +244 -0
  62. package/plugin/vendor/reflexio/reflexio/server/operation_limiter.py +9 -1
  63. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  64. package/plugin/vendor/reflexio/reflexio/server/rate_limit.py +79 -0
  65. package/plugin/vendor/reflexio/reflexio/server/routes/__init__.py +6 -0
  66. package/plugin/vendor/reflexio/reflexio/server/routes/_common.py +25 -0
  67. package/plugin/vendor/reflexio/reflexio/server/routes/_metering.py +98 -0
  68. package/plugin/vendor/reflexio/reflexio/server/routes/braintrust.py +129 -0
  69. package/plugin/vendor/reflexio/reflexio/server/routes/config.py +210 -0
  70. package/plugin/vendor/reflexio/reflexio/server/routes/evaluation.py +549 -0
  71. package/plugin/vendor/reflexio/reflexio/server/routes/interactions.py +259 -0
  72. package/plugin/vendor/reflexio/reflexio/server/routes/playbooks.py +578 -0
  73. package/plugin/vendor/reflexio/reflexio/server/routes/profiles.py +423 -0
  74. package/plugin/vendor/reflexio/reflexio/server/routes/provenance.py +345 -0
  75. package/plugin/vendor/reflexio/reflexio/server/routes/search.py +349 -0
  76. package/plugin/vendor/reflexio/reflexio/server/routes/system.py +261 -0
  77. package/plugin/vendor/reflexio/reflexio/server/scheduling.py +132 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/README.md +5 -2
  79. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/__init__.py +38 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_batch_progress.py +298 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_config_filter.py +152 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_extraction_lifecycle.py +243 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_should_run.py +299 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_status_change.py +273 -0
  85. package/plugin/vendor/reflexio/reflexio/server/services/base_generation/_usage_billing.py +258 -0
  86. package/plugin/vendor/reflexio/reflexio/server/services/base_generation_service.py +17 -1183
  87. package/plugin/vendor/reflexio/reflexio/server/services/deduplication_utils.py +8 -1
  88. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_scheduler.py +26 -41
  89. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  90. package/plugin/vendor/reflexio/reflexio/server/services/generation_service.py +232 -123
  91. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  92. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  93. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  94. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +385 -78
  95. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  96. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  97. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +121 -525
  98. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_clustering.py +184 -0
  99. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_postprocessing.py +130 -0
  100. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator_prompt_formatting.py +212 -0
  101. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/consolidator.py +258 -69
  102. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  103. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  104. package/plugin/vendor/reflexio/reflexio/server/services/publish_learning_worker.py +288 -0
  105. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  106. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  107. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  108. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  109. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +43 -6
  110. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_agent_run.py +10 -1167
  111. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +58 -351
  112. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  113. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +452 -0
  114. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +11 -4
  115. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  116. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +7 -1126
  117. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  118. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_share_links.py +30 -0
  119. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/__init__.py +9 -0
  120. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_agent_run_store.py +506 -0
  121. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_pending_tool_call_store.py +704 -0
  122. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/agent_run/_run_tool_dependency_store.py +123 -0
  123. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/__init__.py +6 -0
  124. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_deletion.py +263 -0
  125. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/base/_fts_vec.py +132 -0
  126. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/__init__.py +13 -0
  127. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_audit.py +122 -0
  128. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_erase_execution.py +465 -0
  129. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_purge.py +387 -0
  130. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_rebuild_hide.py +332 -0
  131. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/governance/_subject_barrier.py +511 -0
  132. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  133. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +955 -0
  134. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  135. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  136. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  137. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +844 -0
  138. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/__init__.py +9 -0
  139. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_interaction_store.py +263 -0
  140. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_profile_store.py +896 -0
  141. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/profiles/_search.py +270 -0
  142. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +50 -9
  143. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_agent_run.py +64 -370
  144. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  145. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  146. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  147. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_share_links.py +20 -0
  148. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/__init__.py +9 -0
  149. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_agent_run_store.py +86 -0
  150. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_models.py +195 -0
  151. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_pending_tool_call_store.py +148 -0
  152. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/agent_run/_run_tool_dependency_store.py +38 -0
  153. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/__init__.py +13 -0
  154. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_audit.py +29 -0
  155. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_erase_execution.py +30 -0
  156. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_purge.py +74 -0
  157. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_rebuild_hide.py +32 -0
  158. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/governance/_subject_barrier.py +49 -0
  159. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  160. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  161. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  162. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  163. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  164. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  165. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/__init__.py +9 -0
  166. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_interaction_store.py +73 -0
  167. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/{_profiles.py → profiles/_profile_store.py} +57 -86
  168. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/profiles/_search.py +32 -0
  169. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  170. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
@@ -48,6 +48,10 @@ def parse_item_id(item_id: str) -> tuple[str, int] | None:
48
48
  """
49
49
  Parse a prompt-format item ID like 'NEW-0' or 'EXISTING-1' into its prefix and index.
50
50
 
51
+ Weak models sometimes echo the rendered display label with its brackets
52
+ (``[NEW-0]``); strip a single surrounding pair so those outputs parse
53
+ instead of being silently dropped.
54
+
51
55
  Args:
52
56
  item_id (str): Item ID string in the format 'PREFIX-N' (e.g., 'NEW-0', 'EXISTING-1')
53
57
 
@@ -55,7 +59,10 @@ def parse_item_id(item_id: str) -> tuple[str, int] | None:
55
59
  Optional[tuple[str, int]]: A tuple of (prefix, index) where prefix is 'NEW' or 'EXISTING',
56
60
  or None if the item ID is invalid
57
61
  """
58
- parts = item_id.rsplit("-", 1)
62
+ stripped = item_id.strip()
63
+ if stripped.startswith("[") and stripped.endswith("]"):
64
+ stripped = stripped[1:-1].strip()
65
+ parts = stripped.rsplit("-", 1)
59
66
  if len(parts) != 2:
60
67
  logger.warning("Invalid item ID format: %s", item_id)
61
68
  return None
@@ -11,11 +11,11 @@ with — never another tenant's runs.
11
11
  from __future__ import annotations
12
12
 
13
13
  import logging
14
- import threading
15
14
  from collections.abc import Callable
16
15
  from datetime import UTC, datetime
17
16
 
18
17
  from reflexio.server.api_endpoints.request_context import RequestContext
18
+ from reflexio.server.scheduling import ThreadedScheduler
19
19
  from reflexio.server.services.extraction.resumable_agent import (
20
20
  pending_tool_calls_enabled,
21
21
  )
@@ -27,7 +27,7 @@ logger = logging.getLogger(__name__)
27
27
  _DEFAULT_POLL_INTERVAL_SECONDS = 5.0
28
28
 
29
29
 
30
- class ExtractionResumeScheduler:
30
+ class ExtractionResumeScheduler(ThreadedScheduler):
31
31
  """Small polling wrapper that drives :class:`ExtractionResumeWorker` per org."""
32
32
 
33
33
  def __init__(
@@ -37,29 +37,15 @@ class ExtractionResumeScheduler:
37
37
  bootstrap_org_id: str,
38
38
  max_runs_per_tick: int = 10,
39
39
  ) -> None:
40
+ super().__init__(thread_name="reflexio-extraction-resume-scheduler")
40
41
  self.request_context_factory = request_context_factory
41
42
  self.bootstrap_org_id = bootstrap_org_id
42
43
  self.max_runs_per_tick = max_runs_per_tick
43
- self._stop_event = threading.Event()
44
- self._thread: threading.Thread | None = None
45
44
 
46
- def start(self) -> None:
47
- if self._thread is not None and self._thread.is_alive():
48
- return
49
- self._stop_event.clear()
50
- self._thread = threading.Thread(
51
- target=self._run_loop,
52
- name="reflexio-extraction-resume-scheduler",
53
- daemon=True,
54
- )
55
- self._thread.start()
45
+ def _on_started(self) -> None:
56
46
  logger.info("event=extraction_resume_scheduler_started")
57
47
 
58
- def stop(self, *, timeout_seconds: float = 5.0) -> None:
59
- self._stop_event.set()
60
- if self._thread is not None:
61
- self._thread.join(timeout=timeout_seconds)
62
- self._thread = None
48
+ def _on_stopped(self) -> None:
63
49
  logger.info("event=extraction_resume_scheduler_stopped")
64
50
 
65
51
  def _discover_org_ids(self, bootstrap_ctx: RequestContext) -> list[str]:
@@ -116,28 +102,27 @@ class ExtractionResumeScheduler:
116
102
  org_id,
117
103
  )
118
104
 
119
- def _run_loop(self) -> None:
120
- while not self._stop_event.is_set():
121
- poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
122
- try:
123
- bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
124
- config = bootstrap_ctx.configurator.get_config()
125
- poll_interval = (
126
- config.pending_tool_call_config.resume_poll_interval_seconds
127
- )
128
- self._expire_pending_tool_calls(bootstrap_ctx)
129
- for org_id in self._discover_org_ids(bootstrap_ctx):
130
- if self._stop_event.is_set():
131
- break
132
- self._drain_org(org_id)
133
- except Exception as exc:
134
- with sentry_tags(
135
- subsystem="extraction",
136
- op="scheduler_tick",
137
- error_type=type(exc).__name__,
138
- ):
139
- logger.exception("event=extraction_resume_scheduler_tick_failed")
140
- self._stop_event.wait(poll_interval)
105
+ def _run_once(self) -> float:
106
+ poll_interval = _DEFAULT_POLL_INTERVAL_SECONDS
107
+ try:
108
+ bootstrap_ctx = self.request_context_factory(self.bootstrap_org_id)
109
+ config = bootstrap_ctx.configurator.get_config()
110
+ poll_interval = (
111
+ config.pending_tool_call_config.resume_poll_interval_seconds
112
+ )
113
+ self._expire_pending_tool_calls(bootstrap_ctx)
114
+ for org_id in self._discover_org_ids(bootstrap_ctx):
115
+ if self._stop_event.is_set():
116
+ break
117
+ self._drain_org(org_id)
118
+ except Exception as exc:
119
+ with sentry_tags(
120
+ subsystem="extraction",
121
+ op="scheduler_tick",
122
+ error_type=type(exc).__name__,
123
+ ):
124
+ logger.exception("event=extraction_resume_scheduler_tick_failed")
125
+ return poll_interval
141
126
 
142
127
 
143
128
  def maybe_start_resume_scheduler(
@@ -484,11 +484,18 @@ class ExtractionResumeWorker:
484
484
  raw_profiles = [
485
485
  profile.model_dump() for profile in result.output.profiles or []
486
486
  ]
487
+ source_interaction_ids = [
488
+ interaction.interaction_id
489
+ for request_model in request_interaction_data_models
490
+ for interaction in request_model.interactions
491
+ if interaction.interaction_id
492
+ ]
487
493
  return (
488
494
  extractor._convert_raw_to_user_profiles(
489
495
  raw_profiles=raw_profiles,
490
496
  user_id=run.binding.user_id,
491
497
  request_id=run.binding.request_id,
498
+ source_interaction_ids=source_interaction_ids,
492
499
  ),
493
500
  result.pending_tool_call_ids,
494
501
  )
@@ -698,6 +705,7 @@ class ExtractionResumeWorker:
698
705
  raw_profiles=raw_profiles,
699
706
  user_id=run.binding.user_id,
700
707
  request_id=run.binding.request_id,
708
+ source_interaction_ids=list(run.binding.source_interaction_ids),
701
709
  ),
702
710
  run.pending_tool_call_ids,
703
711
  )
@@ -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
 
@@ -22,6 +23,7 @@ from reflexio.models.api_schema.service_schemas import (
22
23
  from reflexio.models.config_schema import Config
23
24
  from reflexio.server.api_endpoints.request_context import RequestContext
24
25
  from reflexio.server.llm.litellm_client import LiteLLMClient
26
+ from reflexio.server.operation_limiter import operation_limit
25
27
  from reflexio.server.services.agent_success_evaluation.runner import (
26
28
  run_group_evaluation,
27
29
  )
@@ -140,7 +142,12 @@ class GenerationService:
140
142
  # ===============================
141
143
 
142
144
  def run(
143
- self, publish_user_interaction_request: PublishUserInteractionRequest
145
+ self,
146
+ publish_user_interaction_request: PublishUserInteractionRequest,
147
+ *,
148
+ use_publish_limiter: bool = True,
149
+ publish_limiter_wait_forever: bool = True,
150
+ defer_learning: bool = False,
144
151
  ) -> GenerationServiceResult:
145
152
  """
146
153
  Process a user interaction request by storing interactions and triggering generation services.
@@ -156,6 +163,14 @@ class GenerationService:
156
163
 
157
164
  Args:
158
165
  publish_user_interaction_request: The incoming user interaction request
166
+ use_publish_limiter: Whether this service should throttle the post-write
167
+ learning pipeline. Server sync calls acquire the bounded API
168
+ limiter before invoking this service, so they disable the inner
169
+ limiter to avoid waiting after storage side effects.
170
+ publish_limiter_wait_forever: Whether to queue indefinitely for the
171
+ post-write learning limiter when ``use_publish_limiter`` is true.
172
+ defer_learning: Whether to persist the publish and enqueue learning
173
+ for a process-local worker instead of running it inline.
159
174
 
160
175
  Returns:
161
176
  GenerationServiceResult: The request_id assigned to this publish call
@@ -293,137 +308,99 @@ class GenerationService:
293
308
  )
294
309
  return result
295
310
 
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
- )
311
+ if defer_learning:
312
+ from reflexio.server.services.publish_learning_worker import (
313
+ PublishLearningJob,
314
+ enqueue_publish_learning,
315
+ )
307
316
 
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
- )
317
+ self._schedule_group_evaluation_if_needed(
318
+ new_request=new_request,
319
+ user_id=user_id,
320
+ agent_version=agent_version,
321
+ source=source,
322
+ )
323
+ learning_queued = enqueue_publish_learning(
324
+ PublishLearningJob(
325
+ org_id=self.org_id,
326
+ user_id=user_id,
327
+ request_id=request_id,
328
+ session_id=new_request.session_id,
329
+ source=source,
330
+ agent_version=agent_version,
331
+ force_extraction=publish_user_interaction_request.force_extraction,
332
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
333
+ )
334
+ )
335
+ record_usage_event(
336
+ org_id=self.org_id,
337
+ user_id=user_id,
338
+ request_id=request_id,
339
+ session_id=new_request.session_id,
340
+ source=source,
341
+ agent_version=agent_version,
342
+ backend="classic",
343
+ event_name="publish_request_succeeded",
344
+ event_category="publish",
345
+ outcome="success",
346
+ count_value=len(new_interactions),
347
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
348
+ metadata={
349
+ "defer_learning": True,
350
+ "learning_queued": learning_queued,
351
+ "warning_count": len(result.warnings),
352
+ },
353
+ )
354
+ return result
319
355
 
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,
324
- )
325
- playbook_generation_request = PlaybookGenerationRequest(
326
- request_id=request_id,
327
- agent_version=agent_version,
328
- user_id=user_id,
329
- source=source,
330
- force_extraction=publish_user_interaction_request.force_extraction,
356
+ # The durable write above must not sit behind the publish limiter:
357
+ # async API callers have already received success=True. Throttle only
358
+ # the post-write learning pipeline so a hung LLM cannot silently drop
359
+ # later acknowledged publishes before they reach storage.
360
+ learning_limit = (
361
+ operation_limit(
362
+ self.org_id,
363
+ "publish",
364
+ wait_forever=publish_limiter_wait_forever,
365
+ )
366
+ if use_publish_limiter
367
+ else nullcontext()
331
368
  )
369
+ with learning_limit:
370
+ self._run_learning_steps(
371
+ user_id=user_id,
372
+ request_id=request_id,
373
+ agent_version=agent_version,
374
+ force_extraction=publish_user_interaction_request.force_extraction,
375
+ skip_aggregation=publish_user_interaction_request.skip_aggregation,
376
+ source=source,
377
+ result=result,
378
+ )
332
379
 
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
338
- executor = ThreadPoolExecutor(max_workers=2)
339
- try:
340
- # Each thread needs its own context copy — Context.run() is non-reentrant
341
- futures = [
342
- executor.submit(
343
- contextvars.copy_context().run,
344
- profile_generation_service.run,
345
- profile_generation_request,
346
- ),
347
- executor.submit(
348
- contextvars.copy_context().run,
349
- playbook_generation_service.run,
350
- playbook_generation_request,
351
- ),
352
- ]
353
-
354
- # Collect results and handle any exceptions
355
- # Each service failure is logged but doesn't block others
356
- service_names = ["profile_generation", "playbook_generation"]
357
- for future, service_name in zip(futures, service_names, strict=True):
358
- try:
359
- future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
360
- except FuturesTimeoutError: # noqa: PERF203
361
- msg = f"{service_name} timed out after {GENERATION_SERVICE_TIMEOUT_SECONDS}s"
362
- with sentry_tags(
363
- subsystem="generation",
364
- service=service_name,
365
- request_id=request_id,
366
- error_type="timeout",
367
- ):
368
- logger.error("%s for request %s", msg, request_id)
369
- result.warnings.append(msg)
370
- except Exception as e:
371
- msg = f"{service_name} failed: {e}"
372
- with sentry_tags(
373
- subsystem="generation",
374
- service=service_name,
375
- request_id=request_id,
376
- error_type=type(e).__name__,
377
- ):
378
- logger.exception(
379
- "Generation service failed for request %s",
380
- request_id,
381
- )
382
- result.warnings.append(msg)
383
- finally:
384
- executor.shutdown(wait=False, cancel_futures=True)
380
+ # Schedule delayed group evaluation for the required session.
381
+ self._schedule_group_evaluation_if_needed(
382
+ new_request=new_request,
383
+ user_id=user_id,
384
+ agent_version=agent_version,
385
+ source=source,
386
+ )
385
387
 
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(
388
+ record_usage_event(
391
389
  org_id=self.org_id,
392
390
  user_id=user_id,
391
+ request_id=request_id,
392
+ session_id=new_request.session_id,
393
+ source=source,
393
394
  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,
395
+ backend="classic",
396
+ event_name="publish_request_succeeded",
397
+ event_category="publish",
398
+ outcome="success",
399
+ count_value=len(new_interactions),
400
+ duration_ms=int((time.perf_counter() - publish_start) * 1000),
401
+ metadata={"warning_count": len(result.warnings)},
401
402
  )
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(
412
- org_id=self.org_id,
413
- user_id=user_id,
414
- request_id=request_id,
415
- session_id=new_request.session_id,
416
- source=source,
417
- 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)},
425
- )
426
- return result
403
+ return result
427
404
 
428
405
  except Exception as e:
429
406
  record_usage_event(
@@ -453,10 +430,142 @@ class GenerationService:
453
430
  )
454
431
  raise
455
432
 
433
+ # ===============================
434
+ # deferred learning
435
+ # ===============================
436
+
437
+ def run_deferred_learning(
438
+ self,
439
+ *,
440
+ user_id: str,
441
+ request_id: str,
442
+ session_id: str | None, # noqa: ARG002 - kept for worker telemetry symmetry
443
+ source: str | None,
444
+ agent_version: str,
445
+ force_extraction: bool,
446
+ skip_aggregation: bool,
447
+ ) -> GenerationServiceResult:
448
+ result = GenerationServiceResult(request_id=request_id)
449
+ self._run_learning_steps(
450
+ user_id=user_id,
451
+ request_id=request_id,
452
+ agent_version=agent_version,
453
+ force_extraction=force_extraction,
454
+ skip_aggregation=skip_aggregation,
455
+ source=source,
456
+ result=result,
457
+ )
458
+ return result
459
+
456
460
  # ===============================
457
461
  # private methods
458
462
  # ===============================
459
463
 
464
+ def _run_learning_steps(
465
+ self,
466
+ *,
467
+ user_id: str,
468
+ request_id: str,
469
+ agent_version: str,
470
+ force_extraction: bool,
471
+ skip_aggregation: bool,
472
+ source: str | None,
473
+ result: GenerationServiceResult,
474
+ ) -> None:
475
+ # Reflection runs as its own sliding-window step BEFORE the extractor
476
+ # pool spins up, so replacements are visible to extractors.
477
+ self._maybe_run_reflection(
478
+ user_id=user_id,
479
+ request_id=request_id,
480
+ agent_version=agent_version,
481
+ source=source,
482
+ )
483
+
484
+ profile_generation_service = ProfileGenerationService(
485
+ llm_client=self.client, request_context=self.request_context
486
+ )
487
+ profile_generation_request = ProfileGenerationRequest(
488
+ user_id=user_id,
489
+ request_id=request_id,
490
+ source=source,
491
+ force_extraction=force_extraction,
492
+ )
493
+
494
+ playbook_generation_service = PlaybookGenerationService(
495
+ llm_client=self.client,
496
+ request_context=self.request_context,
497
+ skip_aggregation=skip_aggregation,
498
+ )
499
+ playbook_generation_request = PlaybookGenerationRequest(
500
+ request_id=request_id,
501
+ agent_version=agent_version,
502
+ user_id=user_id,
503
+ source=source,
504
+ force_extraction=force_extraction,
505
+ )
506
+
507
+ executor = ThreadPoolExecutor(max_workers=2)
508
+ try:
509
+ futures = [
510
+ executor.submit(
511
+ contextvars.copy_context().run,
512
+ profile_generation_service.run,
513
+ profile_generation_request,
514
+ ),
515
+ executor.submit(
516
+ contextvars.copy_context().run,
517
+ playbook_generation_service.run,
518
+ playbook_generation_request,
519
+ ),
520
+ ]
521
+
522
+ service_names = ["profile_generation", "playbook_generation"]
523
+ for future, service_name in zip(futures, service_names, strict=True):
524
+ try:
525
+ future.result(timeout=GENERATION_SERVICE_TIMEOUT_SECONDS)
526
+ except FuturesTimeoutError: # noqa: PERF203
527
+ msg = (
528
+ f"{service_name} timed out after "
529
+ f"{GENERATION_SERVICE_TIMEOUT_SECONDS}s"
530
+ )
531
+ with sentry_tags(
532
+ subsystem="generation",
533
+ service=service_name,
534
+ request_id=request_id,
535
+ error_type="timeout",
536
+ ):
537
+ logger.error("%s for request %s", msg, request_id)
538
+ result.warnings.append(msg)
539
+ except Exception as e:
540
+ msg = f"{service_name} failed: {e}"
541
+ with sentry_tags(
542
+ subsystem="generation",
543
+ service=service_name,
544
+ request_id=request_id,
545
+ error_type=type(e).__name__,
546
+ ):
547
+ logger.exception(
548
+ "Generation service failed for request %s",
549
+ request_id,
550
+ )
551
+ result.warnings.append(msg)
552
+ finally:
553
+ executor.shutdown(wait=False, cancel_futures=True)
554
+
555
+ try:
556
+ schedule_tagging(
557
+ org_id=self.org_id,
558
+ user_id=user_id,
559
+ agent_version=agent_version,
560
+ request_context=self.request_context,
561
+ llm_client=self.client,
562
+ )
563
+ except Exception:
564
+ logger.exception(
565
+ "Failed to schedule tagging for publish request %s",
566
+ request_id,
567
+ )
568
+
460
569
  def _schedule_group_evaluation_if_needed(
461
570
  self,
462
571
  *,
@@ -0,0 +1,52 @@
1
+ from __future__ import annotations
2
+
3
+ from reflexio.server.env_utils import env_str, env_truthy
4
+ from reflexio.server.services.governance.subject_refs import (
5
+ actor_ref,
6
+ request_ref,
7
+ subject_ref,
8
+ )
9
+
10
+ LOCAL_GOVERNANCE_REF_SECRET = "reflexio-local-governance-ref-secret" # noqa: S105
11
+ _PRODUCTION_DEPLOYMENT_MODES = {"platform", "self_host"}
12
+ _PRODUCTION_ENVS = {"production", "staging"}
13
+
14
+
15
+ def _is_local_dev_or_test() -> bool:
16
+ deployment_mode = env_str("DEPLOYMENT_MODE").lower()
17
+ reflexio_env = env_str("REFLEXIO_ENV", "development").lower()
18
+ if env_truthy(env_str("REFLEXIO_TEST_MODE")):
19
+ return True
20
+ return (
21
+ deployment_mode not in _PRODUCTION_DEPLOYMENT_MODES
22
+ and reflexio_env not in _PRODUCTION_ENVS
23
+ )
24
+
25
+
26
+ def get_governance_ref_secret() -> str:
27
+ secret = env_str("REFLEXIO_GOVERNANCE_REF_SECRET")
28
+ if secret:
29
+ return secret
30
+ if _is_local_dev_or_test():
31
+ return LOCAL_GOVERNANCE_REF_SECRET
32
+ raise RuntimeError("REFLEXIO_GOVERNANCE_REF_SECRET is required")
33
+
34
+
35
+ def governance_subject_ref(org_id: str, user_id: str, secret: str) -> str:
36
+ return subject_ref(f"{org_id}:subject:{user_id}", secret)
37
+
38
+
39
+ def governance_request_ref(org_id: str, request_id: str, secret: str) -> str:
40
+ return request_ref(f"{org_id}:request:{request_id}", secret)
41
+
42
+
43
+ def governance_actor_ref(
44
+ org_id: str,
45
+ credential_kind: str,
46
+ principal_or_fingerprint: str,
47
+ secret: str,
48
+ ) -> str:
49
+ return actor_ref(
50
+ f"{org_id}:actor:{credential_kind}:{principal_or_fingerprint}",
51
+ secret,
52
+ )