Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
accc572
fix: preserve normalized config identity
guangyu-reflexio Jul 23, 2026
7f5be58
feat: add durable optimizer job contracts
guangyu-reflexio Jul 24, 2026
6888601
fix: fence durable optimizer artifact writes
guangyu-reflexio Jul 24, 2026
42db13a
fix: persist retrieved-learning sampling decisions
guangyu-reflexio Jul 24, 2026
2e14f78
test: type retrieved-learning sampling mocks
guangyu-reflexio Jul 24, 2026
9a56acb
feat: add atomic user playbook publication
guangyu-reflexio Jul 24, 2026
630e6ce
fix: enforce publication trust boundaries
guangyu-reflexio Jul 25, 2026
0599f11
fix: reject expired publication leases
guangyu-reflexio Jul 25, 2026
1e5065f
refactor: publish GEPA user playbooks atomically
guangyu-reflexio Jul 25, 2026
6709111
fix: make GEPA publication proofs resumable
guangyu-reflexio Jul 25, 2026
90bfadd
refactor: remove obsolete playbook edit helper
guangyu-reflexio Jul 25, 2026
55b3304
fix: align GEPA publication authority
guangyu-reflexio Jul 25, 2026
fefffeb
fix: bind GEPA evaluator identity
guangyu-reflexio Jul 25, 2026
772f4f9
fix: verify frozen GEPA evaluator plan
guangyu-reflexio Jul 25, 2026
1e59c3f
fix: enforce GEPA plan at provider boundary
guangyu-reflexio Jul 25, 2026
eb2fbbd
feat: add durable aggregation outbox
guangyu-reflexio Jul 25, 2026
56fdce3
fix: harden durable aggregation execution
guangyu-reflexio Jul 25, 2026
6e82451
feat: add optimization governance retention seams
guangyu-reflexio Jul 25, 2026
aa2b6cd
test: cover replay successor CLI search
guangyu-reflexio Jul 26, 2026
3f3a78c
fix: satisfy repository type checks
guangyu-reflexio Jul 26, 2026
267ba90
fix: bind publication to incumbent semantics
guangyu-reflexio Jul 26, 2026
561d12c
fix: enforce offline tuner reservation contracts
guangyu-reflexio Jul 26, 2026
f430ede
fix: detect bound ports without lsof
guangyu-reflexio Jul 26, 2026
a87e310
test: keep OSS e2e credential-free
guangyu-reflexio Jul 26, 2026
cc0e608
fix: persist offline publication source correctly
guangyu-reflexio Jul 26, 2026
8bd852e
chore: satisfy publication static gates
guangyu-reflexio Jul 26, 2026
bf7f9f6
feat: expose aggregation client contracts
guangyu-reflexio Jul 26, 2026
dfa26d6
test: run credential e2e without llm mocks
guangyu-reflexio Jul 27, 2026
5c88b2a
test: align resumable credential e2e with playbooks
guangyu-reflexio Jul 27, 2026
a965eb6
fix: address offline tuner review findings
guangyu-reflexio Jul 28, 2026
097e2ba
fix: sanitize retrieved-learning request logging
guangyu-reflexio Jul 28, 2026
74b3c96
fix: preserve durable managed config writes
guangyu-reflexio Jul 28, 2026
2655f57
fix: preserve aggregation provenance across managed effects
guangyu-reflexio Jul 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 24 additions & 7 deletions docs/playbook_optimizer_assistant_backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,9 @@ The "assistant backend" is the thing that turns *(messages, playbooks)* into the
│ ) │ │
│ │ │
│ 7. if best passes commit thresholds: │ │
│ _commit_if_allowed → archive incumbent, save successor │ │
│ agent target → _commit_if_allowed │ │
│ user target → stage proof + projection, then atomically │ │
│ replace incumbent with one successor │ │
└───────────────────────────────────────────────────────────────────────┼──────────────┘
│ for each
Expand Down Expand Up @@ -262,7 +264,15 @@ Useful as a smoke test — confirms the optimizer end-to-end without needing an

## 5. How a backend is selected at runtime

Selection happens once per `optimize()` call inside `PlaybookOptimizer._create_assistant`:
Before selecting a backend, `optimize()` applies the feature, target-kind, and
adoption kill switches. For user-playbook targets it then attempts to recover
any expired staged publication. Recovery uses the already durable candidate,
evaluation digests, decision proof, and search projection; it does not rerun
GEPA or require an assistant backend. A disabled kill switch prevents both a
new run and publication recovery.

For a new optimization run, backend selection happens once per `optimize()`
call inside `PlaybookOptimizer._create_assistant`:
Comment thread
coderabbitai[bot] marked this conversation as resolved.

```python
def _create_assistant(self, config) -> AssistantCallable | None:
Expand All @@ -280,7 +290,11 @@ def _create_assistant(self, config) -> AssistantCallable | None:
| Both set | **Rejected at config load time** by a Pydantic validator (`ValueError: Configure only one playbook optimizer assistant backend...`) |
| Neither set | Optimizer logs `Skipping playbook optimization: no assistant backend configured` and returns without creating a job |

The backend check happens *before* loading the incumbent or resolving scenario windows, so an unconfigured optimizer short-circuits cheaply. After source-window resolution, the optimizer also skips before job creation when the validation holdout is smaller than `min_commit_windows` or the target kind has no enabled auto-update path.
The backend check happens *before* loading the incumbent or resolving scenario
windows, so an unconfigured optimizer short-circuits cheaply when there is no
recoverable publication. After source-window resolution, the optimizer also
skips before job creation when the validation holdout is smaller than
`min_commit_windows`.

---

Expand Down Expand Up @@ -362,13 +376,13 @@ playbook_optimizer_config:

## 7. End-to-end workflow

What happens, in order, when a new agent playbook is generated and the optimizer is enabled:
What happens, in order, when a playbook is scheduled and the optimizer is enabled:

| Step | Component | What it does |
|---|---|---|
| 1 | `PlaybookAggregator._enqueue_playbook_optimization` | After saving new agent playbooks, enqueues each PENDING playbook with the scheduler. |
| 2 | `PlaybookOptimizationScheduler` | Debounces by `(org_id, kind, target_id)`, fires after a small jitter, spawns a daemon thread. |
| 3 | `PlaybookOptimizer.optimize(target)` | Loads config, calls `_create_assistant` → backend instance (or returns early). |
| 3 | `PlaybookOptimizer.optimize(target)` | Applies feature, target, and adoption gates. For user targets, resumes an expired staged publication before starting new search work. |
| 4 | `ScenarioResolver` | Builds `ScenarioWindow`s from snapshotted agent source windows or a user playbook's `source_interaction_ids`. |
| 5 | `gepa.api.optimize(...)` | Runs the candidate-search loop. |
| 6 | `ReflexioPlaybookGEPAAdapter.evaluate` | For each `(candidate, window)`: |
Expand All @@ -378,9 +392,12 @@ What happens, in order, when a new agent playbook is generated and the optimizer
| | | • Persists `PlaybookOptimizationEvaluation` row |
| 7 | Failure handling | Any `AssistantFailedError` → `verdict="aborted"`, `score=0.0`, GEPA continues. |
| 8 | `PlaybookOptimizer._passes_commit_thresholds` | Checks score / likert / per-window verdict counts. |
| 9 | `PlaybookOptimizer._commit_if_allowed` | If gates pass → archive incumbent, save successor playbook. |
| 9a | Agent-playbook publication | `_commit_if_allowed` archives the pending incumbent and saves its successor. |
| 9b | User-playbook publication | Builds a fixed decision proof and search projection, stages them durably, then atomically replaces the current user playbook with exactly one successor. The compact proof binds each full durable evaluation through a canonical digest rather than duplicating rollout documents. |
| 10 | User-playbook recovery | A later enabled run may reclaim an expired publication lease and finish from the staged bytes. Incumbent changes produce `incumbent_changed`; retries do not rerun GEPA or regenerate the projection. |

The same flow runs for user playbooks, gated by `optimize_user_playbooks`.
User-playbook recovery and new runs both require `enabled`,
`optimize_user_playbooks`, and `auto_update_user_playbooks`.

---

Expand Down
5 changes: 4 additions & 1 deletion reflexio/cli/commands/status_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,18 @@ def check(
render_error(err, json_mode=json_mode)
raise SystemExit(1) from exc
except requests.HTTPError as exc:
response = exc.response
if json_mode:
envelope = {
"ok": False,
"error": {"type": "unhealthy", "message": str(exc), "url": url},
}
print(json.dumps(envelope, indent=2), file=sys.stderr)
elif response is None:
print(f"Error: Server at {url} returned an HTTP error", file=sys.stderr)
else:
print(
f"Error: Server at {url} returned {exc.response.status_code}",
f"Error: Server at {url} returned {response.status_code}",
file=sys.stderr,
)
raise SystemExit(1) from exc
Expand Down
5 changes: 3 additions & 2 deletions reflexio/cli/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ def _classify_http_error(exc: requests.HTTPError) -> CliError:
)
if status == 422:
detail = ""
with contextlib.suppress(ValueError, AttributeError):
detail = exc.response.json().get("detail", "")
if exc.response is not None:
with contextlib.suppress(ValueError, AttributeError):
detail = exc.response.json().get("detail", "")
return CliError(
error_type="validation",
message=f"Invalid request: {detail or exc}",
Expand Down
28 changes: 15 additions & 13 deletions reflexio/cli/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,21 +71,23 @@ def _pids_from_lsof(port: int) -> list[int]:
check=False,
)
except FileNotFoundError:
return []
if result.returncode != 0 or not result.stdout.strip():
return []
result = None

pids: set[int] = set()
current_pid: int | None = None
suffix = f":{port}"
for line in result.stdout.splitlines():
if line.startswith("p") and line[1:].isdigit():
current_pid = int(line[1:])
elif line.startswith("n") and current_pid is not None:
name = line[1:]
if "->" not in name and name.endswith(suffix):
pids.add(current_pid)
return sorted(pids)
if result is not None and result.returncode == 0:
current_pid: int | None = None
suffix = f":{port}"
for line in result.stdout.splitlines():
if line.startswith("p") and line[1:].isdigit():
current_pid = int(line[1:])
elif line.startswith("n") and current_pid is not None:
name = line[1:]
if "->" not in name and name.endswith(suffix):
pids.add(current_pid)
if pids:
return sorted(pids)

return []


def _pids_from_ss(port: int) -> list[int]:
Expand Down
4 changes: 2 additions & 2 deletions reflexio/client/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .client import ReflexioClient
from .client import ConfigResponse, OfflineTunerConfigResponse, ReflexioClient

__all__ = ["ReflexioClient"]
__all__ = ["ConfigResponse", "OfflineTunerConfigResponse", "ReflexioClient"]
29 changes: 20 additions & 9 deletions reflexio/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import aiohttp
import requests
from pydantic import ConfigDict
from pydantic import BaseModel, ConfigDict

from reflexio.defaults import DEFAULT_AGENT_VERSION
from reflexio.models.api_schema.eval_overview_schema import (
Expand Down Expand Up @@ -135,7 +135,12 @@
from .cache import InMemoryCache


class _ClientConfigPayload(Config):
class OfflineTunerConfigResponse(BaseModel):
enabled: bool


class ConfigResponse(Config):
offline_tuner_config: OfflineTunerConfigResponse | None = None
model_config = ConfigDict(extra="allow")


Expand Down Expand Up @@ -1467,10 +1472,16 @@ def set_config(self, config: Config | dict) -> dict:
Returns:
dict: Response containing success status and message
"""
config = self._convert_to_model( # type: ignore[reportAssignmentType]
config,
_ClientConfigPayload,
)
if isinstance(config, Config):
config = Config.model_validate(
config.model_dump(mode="python"),
extra="ignore",
)
else:
config = self._convert_to_model( # type: ignore[reportAssignmentType]
config,
Config,
)
return self._make_request(
"POST",
"/api/set_config",
Expand Down Expand Up @@ -1506,17 +1517,17 @@ def update_config(self, partial: dict) -> dict:
)
return self._make_request("POST", "/api/update_config", json=partial)

def get_config(self) -> Config:
def get_config(self) -> ConfigResponse:
"""Get configuration for the organization.

Returns:
Config: The current configuration
ConfigResponse: The current configuration, including response overlays.
"""
response = self._make_request(
"GET",
"/api/get_config",
)
return _ClientConfigPayload(**response)
return ConfigResponse(**response)

def invalidate_cache(self, org_id: str | None = None) -> dict:
"""Explicitly evict the server-side per-org Reflexio cache entry.
Expand Down
17 changes: 15 additions & 2 deletions reflexio/integrations/openclaw/plugin/tests/test_publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@
from __future__ import annotations

import json
from collections.abc import Sequence
from typing import Any

import pytest
from openclaw_smart import publish, state
from openclaw_smart.reflexio_adapter import Adapter


@pytest.fixture(autouse=True)
Expand All @@ -15,11 +18,21 @@ def isolate_state_dir(monkeypatch, tmp_path):
return sessions


class _Adapter:
class _Adapter(Adapter):
def __init__(self) -> None:
super().__init__()
self.calls = 0

def publish(self, **_kwargs) -> bool: # noqa: ANN003
def publish(
self,
*,
session_id: str,
project_id: str,
interactions: Sequence[dict[str, Any]],
force_extraction: bool = False,
skip_aggregation: bool = False,
) -> bool:
del session_id, project_id, interactions, force_extraction, skip_aggregation
self.calls += 1
return True

Expand Down
54 changes: 27 additions & 27 deletions reflexio/lib/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@
from reflexio.models.config_schema import SearchMode
from reflexio.server.api_endpoints.request_context import RequestContext
from reflexio.server.llm.litellm_client import LiteLLMClient, LiteLLMConfig
from reflexio.server.llm.model_defaults import (
ModelRole,
resolve_model_name,
)
from reflexio.server.llm.model_defaults import ModelRole, resolve_model_name
from reflexio.server.services.configurator.base_configurator import BaseConfigurator
from reflexio.server.services.storage.storage_base import BaseStorage
from reflexio.server.site_var.site_var_manager import SiteVarManager
Expand All @@ -31,6 +28,31 @@
)


def create_generation_litellm_client(
request_context: RequestContext,
) -> LiteLLMClient:
"""Build the tenant client used by normal generation and aggregation."""
model_setting = SiteVarManager().get_site_var("llm_model_setting")
site_var = model_setting if isinstance(model_setting, dict) else {}
config = request_context.configurator.get_config()
api_key_config = config.api_key_config if config else None
config_llm_config = config.llm_config if config else None
generation_model_name = resolve_model_name(
ModelRole.GENERATION,
site_var_value=site_var.get("default_generation_model_name"),
config_override=(
config_llm_config.generation_model_name if config_llm_config else None
),
api_key_config=api_key_config,
)
return LiteLLMClient(
LiteLLMConfig(
model=generation_model_name,
api_key_config=api_key_config,
)
)


def _require_storage[T: BaseModel](
response_type: type[T], *, msg_field: str = "message"
) -> Callable[..., Callable[..., T]]:
Expand Down Expand Up @@ -88,29 +110,7 @@ def __init__(
org_id=org_id, storage_base_dir=storage_base_dir, configurator=configurator
)

# Create single LLM client for all services
model_setting = SiteVarManager().get_site_var("llm_model_setting")
site_var = model_setting if isinstance(model_setting, dict) else {}

# Get API key config and LLM config from configuration if available
config = self.request_context.configurator.get_config()
api_key_config = config.api_key_config if config else None
config_llm_config = config.llm_config if config else None

generation_model_name = resolve_model_name(
ModelRole.GENERATION,
site_var_value=site_var.get("default_generation_model_name"),
config_override=config_llm_config.generation_model_name
if config_llm_config
else None,
api_key_config=api_key_config,
)

llm_config = LiteLLMConfig(
model=generation_model_name,
api_key_config=api_key_config,
)
self.llm_client = LiteLLMClient(llm_config)
self.llm_client = create_generation_litellm_client(self.request_context)

def _is_storage_configured(self) -> bool:
"""Check if storage is configured and available.
Expand Down
13 changes: 11 additions & 2 deletions reflexio/lib/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
from reflexio.lib._base import ReflexioBase
from reflexio.models.api_schema.retriever_schema import SetConfigResponse
from reflexio.models.config_schema import Config, StorageConfigManagedSupabase
from reflexio.server.services.configurator.config_storage import (
ConfigWriteConflictError,
)


class ConfigMixin(ReflexioBase):
Expand All @@ -18,8 +21,12 @@ def set_config(self, config: Config | dict) -> SetConfigResponse:
try:
configurator = self.request_context.configurator
if isinstance(config, dict):
config = configurator.normalize_config_payload(config)
config = Config(**config)
normalized = configurator.normalize_config_payload(config)
config = (
normalized
if isinstance(normalized, Config)
else Config(**normalized)
)

# Validate storage connection before setting config.
# If no storage_config provided, or the caller round-tripped the
Expand Down Expand Up @@ -65,6 +72,8 @@ def set_config(self, config: Config | dict) -> SetConfigResponse:
configurator.set_config(config)

return SetConfigResponse(success=True, msg="Configuration set successfully")
except ConfigWriteConflictError:
raise
except Exception as e:
return SetConfigResponse(
success=False, msg=f"Failed to set configuration: {str(e)}"
Expand Down
5 changes: 5 additions & 0 deletions reflexio/lib/generation_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Public factory for the tenant generation client."""

from reflexio.lib._base import create_generation_litellm_client

__all__ = ["create_generation_litellm_client"]
Loading